@@ -18,6 +18,8 @@ import type {
1818 YearlyDataPoint ,
1919} from ' ~/types/chart'
2020import { DATE_INPUT_MAX } from ' ~/utils/input'
21+ import { applyDataCorrection } from ' ~/utils/chart-data-correction'
22+ import { applyBlocklistCorrection , getAnomaliesForPackages } from ' ~/utils/download-anomalies'
2123import { copyAltTextForTrendLineChart } from ' ~/utils/charts'
2224
2325const props = withDefaults (
@@ -51,6 +53,7 @@ const props = withDefaults(
5153
5254const { locale } = useI18n ()
5355const { accentColors, selectedAccentColor } = useAccentColor ()
56+ const { settings } = useSettings ()
5457const { copy, copied } = useClipboard ()
5558
5659const colorMode = useColorMode ()
@@ -929,15 +932,35 @@ watch(
929932
930933const effectiveDataSingle = computed <EvolutionData >(() => {
931934 const state = activeMetricState .value
935+ let data: EvolutionData
932936 if (
933937 selectedMetric .value === DEFAULT_METRIC_ID &&
934938 displayedGranularity .value === DEFAULT_GRANULARITY &&
935939 props .weeklyDownloads ?.length
936940 ) {
937- if (isWeeklyDataset (state .evolution ) && state .evolution .length ) return state .evolution
938- return props .weeklyDownloads
941+ data =
942+ isWeeklyDataset (state .evolution ) && state .evolution .length
943+ ? state .evolution
944+ : props .weeklyDownloads
945+ } else {
946+ data = state .evolution
939947 }
940- return state .evolution
948+
949+ if (isDownloadsMetric .value && data .length ) {
950+ const pkg = effectivePackageNames .value [0 ] ?? props .packageName ?? ' '
951+ if (settings .value .chartFilter .anomaliesFixed ) {
952+ data = applyBlocklistCorrection ({
953+ data ,
954+ packageName: pkg ,
955+ granularity: displayedGranularity .value ,
956+ })
957+ }
958+ return applyDataCorrection (
959+ data as Array <{ value: number }>,
960+ settings .value .chartFilter ,
961+ ) as EvolutionData
962+ }
963+ return data
941964})
942965
943966/**
@@ -971,7 +994,16 @@ const chartData = computed<{
971994 const pointsByPackage = new Map <string , Array <{ timestamp: number ; value: number }>>()
972995
973996 for (const pkg of names ) {
974- const data = state .evolutionsByPackage [pkg ] ?? []
997+ let data = state .evolutionsByPackage [pkg ] ?? []
998+ if (isDownloadsMetric .value && data .length ) {
999+ if (settings .value .chartFilter .anomaliesFixed ) {
1000+ data = applyBlocklistCorrection ({ data , packageName: pkg , granularity })
1001+ }
1002+ data = applyDataCorrection (
1003+ data as Array <{ value: number }>,
1004+ settings .value .chartFilter ,
1005+ ) as EvolutionData
1006+ }
9751007 const points = extractSeriesPoints (granularity , data )
9761008 pointsByPackage .set (pkg , points )
9771009 for (const p of points ) timestampSet .add (p .timestamp )
@@ -1598,6 +1630,23 @@ const chartConfig = computed<VueUiXyConfig>(() => {
15981630 }
15991631})
16001632
1633+ const isDownloadsMetric = computed (() => selectedMetric .value === ' downloads' )
1634+ const showCorrectionControls = shallowRef (false )
1635+
1636+ const packageAnomalies = computed (() => getAnomaliesForPackages (effectivePackageNames .value ))
1637+ const hasAnomalies = computed (() => packageAnomalies .value .length > 0 )
1638+
1639+ function formatAnomalyDate(dateStr : string ) {
1640+ const [y, m, d] = dateStr .split (' -' ).map (Number )
1641+ if (! y || ! m || ! d ) return dateStr
1642+ return new Intl .DateTimeFormat (locale .value , {
1643+ year: ' numeric' ,
1644+ month: ' short' ,
1645+ day: ' numeric' ,
1646+ timeZone: ' UTC' ,
1647+ }).format (new Date (Date .UTC (y , m - 1 , d )))
1648+ }
1649+
16011650// Trigger data loading when the metric is switched
16021651watch (selectedMetric , value => {
16031652 if (! isMounted .value ) return
@@ -1686,6 +1735,117 @@ watch(selectedMetric, value => {
16861735 </button >
16871736 </div >
16881737
1738+ <!-- Download filter controls -->
1739+ <div v-if =" isDownloadsMetric" class =" flex flex-col gap-2" >
1740+ <button
1741+ type =" button"
1742+ class =" self-start flex items-center gap-1 text-2xs font-mono text-fg-subtle hover:text-fg transition-colors"
1743+ @click =" showCorrectionControls = !showCorrectionControls"
1744+ >
1745+ <span
1746+ class =" w-3.5 h-3.5 transition-transform"
1747+ :class =" showCorrectionControls ? 'i-lucide:chevron-down' : 'i-lucide:chevron-right'"
1748+ aria-hidden =" true"
1749+ />
1750+ {{ $t('package.trends.data_correction') }}
1751+ </button >
1752+ <div v-if =" showCorrectionControls" class =" flex items-end gap-3" >
1753+ <label class =" flex flex-col gap-1 flex-1" >
1754+ <span class =" text-2xs font-mono text-fg-subtle tracking-wide uppercase" >
1755+ {{ $t('package.trends.average_window') }}
1756+ <span class =" text-fg-muted" >({{ settings.chartFilter.averageWindow }})</span >
1757+ </span >
1758+ <input
1759+ v-model.number =" settings.chartFilter.averageWindow"
1760+ type =" range"
1761+ min =" 0"
1762+ max =" 20"
1763+ step =" 1"
1764+ class =" accent-[var(--accent-color,var(--fg-subtle))]"
1765+ />
1766+ </label >
1767+ <label class =" flex flex-col gap-1 flex-1" >
1768+ <span class =" text-2xs font-mono text-fg-subtle tracking-wide uppercase" >
1769+ {{ $t('package.trends.smoothing') }}
1770+ <span class =" text-fg-muted" >({{ settings.chartFilter.smoothingTau }})</span >
1771+ </span >
1772+ <input
1773+ v-model.number =" settings.chartFilter.smoothingTau"
1774+ type =" range"
1775+ min =" 0"
1776+ max =" 20"
1777+ step =" 1"
1778+ class =" accent-[var(--accent-color,var(--fg-subtle))]"
1779+ />
1780+ </label >
1781+ <div class =" flex flex-col gap-1 shrink-0" >
1782+ <span
1783+ class =" text-2xs font-mono text-fg-subtle tracking-wide uppercase flex items-center justify-between"
1784+ >
1785+ {{ $t('package.trends.known_anomalies') }}
1786+ <TooltipApp interactive :to =" inModal ? '#chart-modal' : undefined" >
1787+ <button
1788+ type =" button"
1789+ class =" i-lucide:info w-3.5 h-3.5 text-fg-muted cursor-help"
1790+ :aria-label =" $t('package.trends.known_anomalies')"
1791+ />
1792+ <template #content >
1793+ <div class =" flex flex-col gap-3" >
1794+ <p class =" text-xs text-fg-muted" >
1795+ {{ $t('package.trends.known_anomalies_description') }}
1796+ </p >
1797+ <div v-if =" hasAnomalies" >
1798+ <p class =" text-xs text-fg-subtle font-medium" >
1799+ {{ $t('package.trends.known_anomalies_ranges') }}
1800+ </p >
1801+ <ul class =" text-xs text-fg-subtle list-disc list-inside" >
1802+ <li v-for =" a in packageAnomalies" :key =" `${a.packageName}-${a.start}`" >
1803+ {{
1804+ isMultiPackageMode
1805+ ? $t('package.trends.known_anomalies_range_named', {
1806+ packageName: a.packageName,
1807+ start: formatAnomalyDate(a.start),
1808+ end: formatAnomalyDate(a.end),
1809+ })
1810+ : $t('package.trends.known_anomalies_range', {
1811+ start: formatAnomalyDate(a.start),
1812+ end: formatAnomalyDate(a.end),
1813+ })
1814+ }}
1815+ </li >
1816+ </ul >
1817+ </div >
1818+ <p v-else class =" text-xs text-fg-muted" >
1819+ {{ $t('package.trends.known_anomalies_none', effectivePackageNames.length) }}
1820+ </p >
1821+ <div class =" flex justify-end" >
1822+ <LinkBase
1823+ to =" https://github.com/npmx-dev/npmx.dev/edit/main/app/utils/download-anomalies.data.ts"
1824+ class =" text-xs text-accent"
1825+ >
1826+ {{ $t('package.trends.known_anomalies_contribute') }}
1827+ </LinkBase >
1828+ </div >
1829+ </div >
1830+ </template >
1831+ </TooltipApp >
1832+ </span >
1833+ <label
1834+ class =" flex items-center gap-1.5 text-2xs font-mono text-fg-subtle cursor-pointer"
1835+ :class =" { 'opacity-50 pointer-events-none': !hasAnomalies }"
1836+ >
1837+ <input
1838+ v-model =" settings.chartFilter.anomaliesFixed"
1839+ type =" checkbox"
1840+ :disabled =" !hasAnomalies"
1841+ class =" accent-[var(--accent-color,var(--fg-subtle))]"
1842+ />
1843+ {{ $t('package.trends.apply_correction') }}
1844+ </label >
1845+ </div >
1846+ </div >
1847+ </div >
1848+
16891849 <p v-if =" skippedPackagesWithoutGitHub.length > 0" class =" text-2xs font-mono text-fg-subtle" >
16901850 {{ $t('package.trends.contributors_skip', { count: skippedPackagesWithoutGitHub.length }) }}
16911851 {{ skippedPackagesWithoutGitHub.join(', ') }}
0 commit comments