diff --git a/app/components/Package/DownloadAnalytics.vue b/app/components/Package/DownloadAnalytics.vue index 97fc4d3435..41d34df656 100644 --- a/app/components/Package/DownloadAnalytics.vue +++ b/app/components/Package/DownloadAnalytics.vue @@ -12,6 +12,7 @@ const props = defineProps<{ createdIso: string | null }>() +const { locale } = useI18n() const { accentColors, selectedAccentColor } = useAccentColor() const colorMode = useColorMode() const resolvedMode = shallowRef<'light' | 'dark'>('light') @@ -19,13 +20,30 @@ const rootEl = shallowRef(null) const { width } = useElementSize(rootEl) +const chartKey = ref(0) + +let chartRemountTimeoutId: ReturnType | null = null + onMounted(() => { rootEl.value = document.documentElement resolvedMode.value = colorMode.value === 'dark' ? 'dark' : 'light' + + // If the chart is painted too early, built-in auto-sizing does not adapt to the final container size + chartRemountTimeoutId = setTimeout(() => { + chartKey.value += 1 + chartRemountTimeoutId = null + }, 1) +}) + +onBeforeUnmount(() => { + if (chartRemountTimeoutId !== null) { + clearTimeout(chartRemountTimeoutId) + chartRemountTimeoutId = null + } }) const { colors } = useCssVariables( - ['--bg', '--bg-subtle', '--bg-elevated', '--fg-subtle', '--border', '--border-subtle'], + ['--bg', '--fg', '--bg-subtle', '--bg-elevated', '--fg-subtle', '--border', '--border-subtle'], { element: rootEl, watchHtmlAttributes: true, @@ -121,7 +139,7 @@ function isYearlyDataset(data: unknown): data is YearlyDownloadPoint[] { function formatXyDataset( selectedGranularity: ChartTimeGranularity, dataset: EvolutionData, -): { dataset: VueUiXyDatasetItem[] | null; dates: string[] } { +): { dataset: VueUiXyDatasetItem[] | null; dates: number[] } { if (selectedGranularity === 'weekly' && isWeeklyDataset(dataset)) { return { dataset: [ @@ -132,12 +150,7 @@ function formatXyDataset( color: accent.value, }, ], - dates: dataset.map(d => - $t('package.downloads.date_range_multiline', { - start: d.weekStart, - end: d.weekEnd, - }), - ), + dates: dataset.map(d => d.timestampEnd), } } if (selectedGranularity === 'daily' && isDailyDataset(dataset)) { @@ -150,7 +163,7 @@ function formatXyDataset( color: accent.value, }, ], - dates: dataset.map(d => d.day), + dates: dataset.map(d => d.timestamp), } } if (selectedGranularity === 'monthly' && isMonthlyDataset(dataset)) { @@ -163,7 +176,7 @@ function formatXyDataset( color: accent.value, }, ], - dates: dataset.map(d => d.month), + dates: dataset.map(d => d.timestamp), } } if (selectedGranularity === 'yearly' && isYearlyDataset(dataset)) { @@ -176,7 +189,7 @@ function formatXyDataset( color: accent.value, }, ], - dates: dataset.map(d => d.year), + dates: dataset.map(d => d.timestamp), } } return { dataset: null, dates: [] } @@ -198,18 +211,6 @@ function safeMax(a: string, b: string): string { return a.localeCompare(b) >= 0 ? a : b } -function extractDates(dateLabel: string): [string, string] | null { - const matches = dateLabel.match(/\b(\d{4}(?:-\d{2}-\d{2})?)\b/g) // either yyyy or yyyy-mm-dd - if (!matches) return null - - const first = matches.at(0) - const last = matches.at(-1) - - if (!first || !last || first === last) return null - - return [first, last] -} - /** * Two-phase state: * - selectedGranularity: immediate UI @@ -439,7 +440,7 @@ const effectiveData = computed(() => { return evolution.value }) -const chartData = computed<{ dataset: VueUiXyDatasetItem[] | null; dates: string[] }>(() => { +const chartData = computed<{ dataset: VueUiXyDatasetItem[] | null; dates: number[] }>(() => { return formatXyDataset(displayedGranularity.value, effectiveData.value) }) @@ -453,11 +454,39 @@ const loadFile = (link: string, filename: string) => { a.remove() } +const datetimeFormatterOptions = computed(() => { + return { + daily: { + year: 'yyyy-MM-dd', + month: 'yyyy-MM-dd', + day: 'yyyy-MM-dd', + }, + weekly: { + year: 'yyyy-MM-dd', + month: 'yyyy-MM-dd', + day: 'yyyy-MM-dd', + }, + monthly: { + year: 'MMM yyyy', + month: 'MMM yyyy', + day: 'MMM yyyy', + }, + yearly: { + year: 'yyyy', + month: 'yyyy', + day: 'yyyy', + }, + }[selectedGranularity.value] +}) + const config = computed(() => { return { theme: isDarkMode.value ? 'dark' : 'default', chart: { height: isMobile.value ? 950 : 600, + padding: { + bottom: 36, + }, userOptions: { buttons: { pdf: false, @@ -525,10 +554,14 @@ const config = computed(() => { fontSize: isMobile.value ? 32 : 24, }, xAxisLabels: { - show: !isMobile.value, + show: false, values: chartData.value?.dates, - showOnlyAtModulo: true, - modulo: 12, + datetimeFormatter: { + enable: true, + locale: locale.value, + useUTC: true, + options: datetimeFormatterOptions.value, + }, }, yAxis: { formatter, @@ -536,6 +569,18 @@ const config = computed(() => { }, }, }, + timeTag: { + show: true, + backgroundColor: colors.value.bgElevated, + color: colors.value.fg, + fontSize: 16, + circleMarker: { + radius: 3, + color: colors.value.border, + }, + useDefaultFormat: true, + timeFormat: 'yyyy-MM-dd HH:mm:ss', + }, highlighter: { useLine: true, }, @@ -547,32 +592,17 @@ const config = computed(() => { borderColor: 'transparent', backdropFilter: false, backgroundColor: 'transparent', - customFormat: ({ - absoluteIndex, - datapoint, - }: { - absoluteIndex: number - datapoint: Record - }) => { + customFormat: ({ datapoint }: { datapoint: Record }) => { if (!datapoint) return '' const displayValue = formatter({ value: datapoint[0]?.value ?? 0 }) return `
- ${chartData.value?.dates[absoluteIndex]} - ${displayValue} + ${displayValue}
` }, }, zoom: { maxWidth: isMobile.value ? 350 : 500, - customFormat: - displayedGranularity.value !== 'weekly' - ? undefined - : ({ absoluteIndex, side }: { absoluteIndex: number; side: 'left' | 'right' }) => { - const parts = extractDates(chartData.value.dates[absoluteIndex] ?? '') - if (!parts) return '' - return side === 'left' ? parts[0] : parts[1] - }, highlightColor: colors.value.bgElevated, minimap: { show: true, @@ -594,7 +624,7 @@ const config = computed(() => {