diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index 279801992..0d80c44db 100644 --- a/app/components/Package/TrendsChart.vue +++ b/app/components/Package/TrendsChart.vue @@ -18,6 +18,8 @@ import type { YearlyDataPoint, } from '~/types/chart' import { DATE_INPUT_MAX } from '~/utils/input' +import { applyDownloadFilter } from '~/utils/chart-filters' +import { applyBlocklistFilter } from '~/utils/download-anomalies' const props = withDefaults( defineProps<{ @@ -50,6 +52,7 @@ const props = withDefaults( const { locale } = useI18n() const { accentColors, selectedAccentColor } = useAccentColor() +const { settings } = useSettings() const colorMode = useColorMode() const resolvedMode = shallowRef<'light' | 'dark'>('light') const rootEl = shallowRef(null) @@ -926,15 +929,31 @@ watch( const effectiveDataSingle = computed(() => { const state = activeMetricState.value + let data: EvolutionData if ( selectedMetric.value === DEFAULT_METRIC_ID && displayedGranularity.value === DEFAULT_GRANULARITY && props.weeklyDownloads?.length ) { - if (isWeeklyDataset(state.evolution) && state.evolution.length) return state.evolution - return props.weeklyDownloads + data = + isWeeklyDataset(state.evolution) && state.evolution.length + ? state.evolution + : props.weeklyDownloads + } else { + data = state.evolution + } + + if (isDownloadsMetric.value && data.length) { + const pkg = effectivePackageNames.value[0] ?? props.packageName ?? '' + if (settings.value.chartFilter.anomaliesFixed) { + data = applyBlocklistFilter(data, pkg, displayedGranularity.value) + } + return applyDownloadFilter( + data as Array<{ value: number }>, + settings.value.chartFilter, + ) as EvolutionData } - return state.evolution + return data }) /** @@ -968,7 +987,16 @@ const chartData = computed<{ const pointsByPackage = new Map>() for (const pkg of names) { - const data = state.evolutionsByPackage[pkg] ?? [] + let data = state.evolutionsByPackage[pkg] ?? [] + if (isDownloadsMetric.value && data.length) { + if (settings.value.chartFilter.anomaliesFixed) { + data = applyBlocklistFilter(data, pkg, granularity) + } + data = applyDownloadFilter( + data as Array<{ value: number }>, + settings.value.chartFilter, + ) as EvolutionData + } const points = extractSeriesPoints(granularity, data) pointsByPackage.set(pkg, points) for (const p of points) timestampSet.add(p.timestamp) @@ -1583,6 +1611,9 @@ const chartConfig = computed(() => { } }) +const isDownloadsMetric = computed(() => selectedMetric.value === 'downloads') +const showFilterControls = shallowRef(false) + // Trigger data loading when the metric is switched watch(selectedMetric, value => { if (!isMounted.value) return @@ -1671,6 +1702,62 @@ watch(selectedMetric, value => { + +
+ +
+ + + +
+
+

{{ $t('package.trends.contributors_skip', { count: skippedPackagesWithoutGitHub.length }) }} {{ skippedPackagesWithoutGitHub.join(', ') }} diff --git a/app/components/Package/WeeklyDownloadStats.vue b/app/components/Package/WeeklyDownloadStats.vue index 4cb0242ab..0e76df692 100644 --- a/app/components/Package/WeeklyDownloadStats.vue +++ b/app/components/Package/WeeklyDownloadStats.vue @@ -2,7 +2,9 @@ import { VueUiSparkline } from 'vue-data-ui/vue-ui-sparkline' import { useCssVariables } from '~/composables/useColors' import type { WeeklyDataPoint } from '~/types/chart' +import { applyDownloadFilter } from '~/utils/chart-filters' import { OKLCH_NEUTRAL_FALLBACK, lightenOklch } from '~/utils/colors' +import { applyBlocklistFilter } from '~/utils/download-anomalies' import type { RepoRef } from '#shared/utils/git-providers' import type { VueUiSparklineConfig, VueUiSparklineDatasetItem } from 'vue-data-ui' @@ -177,8 +179,18 @@ watch( () => loadWeeklyDownloads(), ) +const filteredDownloads = computed(() => { + let data = weeklyDownloads.value as WeeklyDataPoint[] + if (!data.length) return data + if (settings.value.chartFilter.anomaliesFixed) { + data = applyBlocklistFilter(data, props.packageName, 'weekly') as WeeklyDataPoint[] + } + data = applyDownloadFilter(data, settings.value.chartFilter) as WeeklyDataPoint[] + return data +}) + const dataset = computed(() => - weeklyDownloads.value.map(d => ({ + filteredDownloads.value.map(d => ({ value: d?.value ?? 0, period: $t('package.trends.date_range', { start: d.weekStart ?? '-', diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts index 254804215..d3c3cd46e 100644 --- a/app/composables/useSettings.ts +++ b/app/composables/useSettings.ts @@ -38,6 +38,11 @@ export interface AppSettings { collapsed: string[] animateSparkline: boolean } + chartFilter: { + averageWindow: number + smoothingTau: number + anomaliesFixed: boolean + } } const DEFAULT_SETTINGS: AppSettings = { @@ -55,6 +60,11 @@ const DEFAULT_SETTINGS: AppSettings = { collapsed: [], animateSparkline: true, }, + chartFilter: { + averageWindow: 0, + smoothingTau: 1, + anomaliesFixed: true, + }, } const STORAGE_KEY = 'npmx-settings' diff --git a/app/utils/chart-filters.ts b/app/utils/chart-filters.ts new file mode 100644 index 000000000..943c24320 --- /dev/null +++ b/app/utils/chart-filters.ts @@ -0,0 +1,96 @@ +/** + * Bidirectional moving average. Blends a trailing (left-anchored) and leading + * (right-anchored) average by position so transitions from both fixed endpoints + * are smooth. + * First and last points are preserved. + * + * @param halfWindow - number of points on each side (0 = disabled) + */ +export function movingAverage(data: T[], halfWindow: number): T[] { + if (halfWindow <= 0 || data.length < 3) return data + + const n = data.length + + // Trailing average (anchored to start): average of [max(0, i-halfWindow), i] + const trailing: number[] = Array.from({ length: n }) + for (let i = 0; i < n; i++) { + const lo = Math.max(0, i - halfWindow) + let sum = 0 + for (let j = lo; j <= i; j++) sum += data[j]!.value + trailing[i] = sum / (i - lo + 1) + } + + // Leading average (anchored to end): average of [i, min(n-1, i+halfWindow)] + const leading: number[] = Array.from({ length: n }) + for (let i = 0; i < n; i++) { + const hi = Math.min(n - 1, i + halfWindow) + let sum = 0 + for (let j = i; j <= hi; j++) sum += data[j]!.value + leading[i] = sum / (hi - i + 1) + } + + // Position-based blend: near start → mostly trailing, near end → mostly leading + const result = data.map(d => ({ ...d })) + for (let i = 1; i < n - 1; i++) { + const t = i / (n - 1) + result[i]!.value = (1 - t) * trailing[i]! + t * leading[i]! + } + + return result +} + +/** + * Forward-backward exponential smoothing (zero-phase). + * Smooths without introducing lag — preserves the dynamics/timing of trends. + * First and last points are preserved. + * + * @param tau - time constant (0 = disabled, higher = smoother) + */ +export function smoothing(data: T[], tau: number): T[] { + if (tau <= 0 || data.length < 3) return data + + const alpha = 1 / (1 + tau) + const n = data.length + + // Forward pass + const forward: number[] = Array.from({ length: n }) + forward[0] = data[0]!.value + for (let i = 1; i < n; i++) { + forward[i] = alpha * data[i]!.value + (1 - alpha) * forward[i - 1]! + } + + // Backward pass + const backward: number[] = Array.from({ length: n }) + backward[n - 1] = data[n - 1]!.value + for (let i = n - 2; i >= 0; i--) { + backward[i] = alpha * data[i]!.value + (1 - alpha) * backward[i + 1]! + } + + // Position-based blend: near start → mostly forward, near end → mostly backward + // This ensures smooth transitions from both fixed endpoints + const result = data.map(d => ({ ...d })) + for (let i = 1; i < n - 1; i++) { + const t = i / (n - 1) + result[i]!.value = (1 - t) * forward[i]! + t * backward[i]! + } + + return result +} + +export interface ChartFilterSettings { + averageWindow: number + smoothingTau: number +} + +/** + * Applies moving average then smoothing in sequence. + */ +export function applyDownloadFilter( + data: T[], + settings: ChartFilterSettings, +): T[] { + let result = data + result = movingAverage(result, settings.averageWindow) + result = smoothing(result, settings.smoothingTau) + return result +} diff --git a/app/utils/download-anomalies.data.ts b/app/utils/download-anomalies.data.ts new file mode 100644 index 000000000..370a80bf0 --- /dev/null +++ b/app/utils/download-anomalies.data.ts @@ -0,0 +1,10 @@ +import type { DownloadAnomaly } from './download-anomalies' + +export const DOWNLOAD_ANOMALIES: DownloadAnomaly[] = [ + // vite rogue CI spike + { + packageName: 'vite', + start: { date: '2025-08-04', weeklyDownloads: 33_913_132 }, + end: { date: '2025-09-08', weeklyDownloads: 38_665_727 }, + }, +] diff --git a/app/utils/download-anomalies.ts b/app/utils/download-anomalies.ts new file mode 100644 index 000000000..8390d7718 --- /dev/null +++ b/app/utils/download-anomalies.ts @@ -0,0 +1,109 @@ +import type { ChartTimeGranularity, EvolutionData } from '~/types/chart' +import { DOWNLOAD_ANOMALIES } from './download-anomalies.data' + +export type DownloadAnomalyBound = { + date: string // YYYY-MM-DD + weeklyDownloads: number +} + +export type DownloadAnomaly = { + packageName: string + start: DownloadAnomalyBound + end: DownloadAnomalyBound +} + +function getDateString(point: Record, granularity: ChartTimeGranularity): string { + switch (granularity) { + case 'daily': + return point.day + case 'weekly': + return point.weekStart + case 'monthly': + return `${point.month}-01` + case 'yearly': + return `${point.year}-01-01` + } +} + +/** + * For daily/weekly the point date falls strictly between the anomaly bounds. + * For monthly/yearly the anomaly bounds are truncated to the same resolution + * so that any period overlapping the anomaly is caught (inclusive). + */ +function isDateAffected( + date: string, + anomaly: DownloadAnomaly, + granularity: ChartTimeGranularity, +): boolean { + switch (granularity) { + case 'daily': + case 'weekly': + return date > anomaly.start.date && date < anomaly.end.date + case 'monthly': { + const startMonth = anomaly.start.date.slice(0, 7) + '-01' + const endMonth = anomaly.end.date.slice(0, 7) + '-01' + return date >= startMonth && date <= endMonth + } + case 'yearly': { + const startYear = anomaly.start.date.slice(0, 4) + '-01-01' + const endYear = anomaly.end.date.slice(0, 4) + '-01-01' + return date >= startYear && date <= endYear + } + } +} + +function scaleWeeklyValue(weeklyValue: number, granularity: ChartTimeGranularity): number { + switch (granularity) { + case 'daily': + return Math.round(weeklyValue / 7) + case 'weekly': + return weeklyValue + case 'monthly': + return Math.round((weeklyValue / 7) * 30) + case 'yearly': + return Math.round((weeklyValue / 7) * 365) + } +} + +export function applyBlocklistFilter( + data: EvolutionData, + packageName: string, + granularity: ChartTimeGranularity, +): EvolutionData { + const anomalies = DOWNLOAD_ANOMALIES.filter(a => a.packageName === packageName) + if (!anomalies.length) return data + + // Clone to avoid mutation + const result = (data as Array>).map(d => ({ ...d })) + + for (const anomaly of anomalies) { + // Find indices of affected points + const affectedIndices: number[] = [] + for (let i = 0; i < result.length; i++) { + const date = getDateString(result[i]!, granularity) + if (isDateAffected(date, anomaly, granularity)) { + affectedIndices.push(i) + } + } + + if (!affectedIndices.length) continue + + const firstAffected = affectedIndices[0]! + const lastAffected = affectedIndices[affectedIndices.length - 1]! + + // Use neighbors when available, fall back to scaled weeklyDownloads + const scaledStart = scaleWeeklyValue(anomaly.start.weeklyDownloads, granularity) + const scaledEnd = scaleWeeklyValue(anomaly.end.weeklyDownloads, granularity) + + const startVal = firstAffected > 0 ? result[firstAffected - 1]!.value : scaledStart + const endVal = lastAffected < result.length - 1 ? result[lastAffected + 1]!.value : scaledEnd + + const count = affectedIndices.length + for (let i = 0; i < count; i++) { + const t = (i + 1) / (count + 1) + result[affectedIndices[i]!]!.value = Math.round(startVal + t * (endVal - startVal)) + } + } + + return result as EvolutionData +} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index e7b78c06a..0d1c3f05b 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -374,7 +374,11 @@ "contributors": "Contributors" }, "play_animation": "Play animation", - "pause_animation": "Pause animation" + "pause_animation": "Pause animation", + "filters": "Filters", + "average": "Average", + "smoothing": "Smoothing", + "anomalies_fixed": "Anomalies fixed" }, "downloads": { "title": "Weekly Downloads", diff --git a/i18n/schema.json b/i18n/schema.json index 70eae31df..2d6aeadd1 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1128,6 +1128,18 @@ }, "pause_animation": { "type": "string" + }, + "filters": { + "type": "string" + }, + "average": { + "type": "string" + }, + "smoothing": { + "type": "string" + }, + "anomalies_fixed": { + "type": "string" } }, "additionalProperties": false diff --git a/lunaria/files/en-GB.json b/lunaria/files/en-GB.json index d63604d93..e1214cf04 100644 --- a/lunaria/files/en-GB.json +++ b/lunaria/files/en-GB.json @@ -373,7 +373,11 @@ "contributors": "Contributors" }, "play_animation": "Play animation", - "pause_animation": "Pause animation" + "pause_animation": "Pause animation", + "filters": "Filters", + "average": "Average", + "smoothing": "Smoothing", + "anomalies_fixed": "Anomalies fixed" }, "downloads": { "title": "Weekly Downloads", diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json index e3bdfccf7..2ac34b3b7 100644 --- a/lunaria/files/en-US.json +++ b/lunaria/files/en-US.json @@ -373,7 +373,11 @@ "contributors": "Contributors" }, "play_animation": "Play animation", - "pause_animation": "Pause animation" + "pause_animation": "Pause animation", + "filters": "Filters", + "average": "Average", + "smoothing": "Smoothing", + "anomalies_fixed": "Anomalies fixed" }, "downloads": { "title": "Weekly Downloads",