From b189a1e69b8eaff631ce0aac59d921e943318bf4 Mon Sep 17 00:00:00 2001 From: jycouet Date: Tue, 24 Feb 2026 23:52:27 +0100 Subject: [PATCH 1/8] hampel --- app/components/Package/TrendsChart.vue | 91 ++++++++++++++++++++++-- app/composables/useSettings.ts | 10 +++ app/utils/chart-filters.ts | 95 ++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 app/utils/chart-filters.ts diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index 279801992..eb2e1db02 100644 --- a/app/components/Package/TrendsChart.vue +++ b/app/components/Package/TrendsChart.vue @@ -18,6 +18,7 @@ import type { YearlyDataPoint, } from '~/types/chart' import { DATE_INPUT_MAX } from '~/utils/input' +import { applyDownloadFilter } from '~/utils/chart-filters' const props = withDefaults( defineProps<{ @@ -50,6 +51,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 +928,27 @@ 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) { + return applyDownloadFilter( + data as Array<{ value: number }>, + settings.value.chartFilter, + ) as EvolutionData } - return state.evolution + return data }) /** @@ -968,7 +982,13 @@ 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) { + 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 +1603,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 +1694,66 @@ watch(selectedMetric, value => { + +
+ +
+ + + +
+
+

{{ $t('package.trends.contributors_skip', { count: skippedPackagesWithoutGitHub.length }) }} {{ skippedPackagesWithoutGitHub.join(', ') }} diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts index 254804215..f6a71aa86 100644 --- a/app/composables/useSettings.ts +++ b/app/composables/useSettings.ts @@ -38,6 +38,11 @@ export interface AppSettings { collapsed: string[] animateSparkline: boolean } + chartFilter: { + hampelWindow: number + hampelThreshold: number + smoothingTau: number + } } const DEFAULT_SETTINGS: AppSettings = { @@ -55,6 +60,11 @@ const DEFAULT_SETTINGS: AppSettings = { collapsed: [], animateSparkline: true, }, + chartFilter: { + hampelWindow: 4, + hampelThreshold: 2, + smoothingTau: 1, + }, } 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..5a07174f5 --- /dev/null +++ b/app/utils/chart-filters.ts @@ -0,0 +1,95 @@ +/** + * Hampel filter: replaces outlier values with the local median. + * Uses Median Absolute Deviation (MAD) to detect outliers. + * + * @param data - array of objects with a `value` property + * @param windowSize - half-window size (0 = disabled) + * @param threshold - number of MADs above which a value is considered an outlier + * @returns a new array with outlier values replaced by the local median + */ +export function hampelFilter( + data: T[], + windowSize: number, + threshold: number, +): T[] { + if (windowSize <= 0 || data.length === 0) return data + + const result = data.map(d => ({ ...d })) + const n = data.length + + for (let i = 0; i < n; i++) { + const lo = Math.max(0, i - windowSize) + const hi = Math.min(n - 1, i + windowSize) + + const windowValues: number[] = [] + for (let j = lo; j <= hi; j++) { + windowValues.push(data[j]!.value) + } + windowValues.sort((a, b) => a - b) + + const median = windowValues[Math.floor(windowValues.length / 2)]! + const deviations = windowValues.map(v => Math.abs(v - median)).sort((a, b) => a - b) + const mad = deviations[Math.floor(deviations.length / 2)]! + + // 1.4826 converts MAD to an estimate of the standard deviation + const scaledMad = 1.4826 * mad + + if (scaledMad > 0 && Math.abs(data[i]!.value - median) > threshold * scaledMad) { + result[i]!.value = median + } + } + + return result +} + +/** + * Low-pass (exponential smoothing) filter. + * + * @param data - array of objects with a `value` property + * @param tau - smoothing time constant (0 = disabled, higher = smoother) + * @returns a new array with smoothed values + */ +export function lowPassFilter(data: T[], tau: number): T[] { + if (tau <= 0 || data.length === 0) return data + + const result = data.map(d => ({ ...d })) + const alpha = 1 / (1 + tau) + + result[0]!.value = data[0]!.value + for (let i = 1; i < data.length; i++) { + result[i]!.value = alpha * data[i]!.value + (1 - alpha) * result[i - 1]!.value + } + + return result +} + +export interface ChartFilterSettings { + hampelWindow: number + hampelThreshold: number + smoothingTau: number +} + +/** + * Applies Hampel filter then low-pass smoothing in sequence. + */ +export function applyDownloadFilter( + data: T[], + settings: ChartFilterSettings, +): T[] { + if (data.length < 2) return data + + const firstValue = data[0]!.value + const lastValue = data[data.length - 1]!.value + + let result = data + result = hampelFilter(result, settings.hampelWindow, settings.hampelThreshold) + result = lowPassFilter(result, settings.smoothingTau) + + // Preserve original first and last values + if (result !== data) { + result[0]!.value = firstValue + result[result.length - 1]!.value = lastValue + } + + return result +} From 230585e96d8bc122df7b4c1e0c34d9b3a9d4535d Mon Sep 17 00:00:00 2001 From: jycouet Date: Wed, 25 Feb 2026 00:13:40 +0100 Subject: [PATCH 2/8] IQR multiplier --- app/components/Package/TrendsChart.vue | 38 ++------- app/composables/useSettings.ts | 8 +- app/utils/chart-filters.ts | 108 ++++++++++--------------- 3 files changed, 51 insertions(+), 103 deletions(-) diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index eb2e1db02..d0d7224c0 100644 --- a/app/components/Package/TrendsChart.vue +++ b/app/components/Package/TrendsChart.vue @@ -1708,49 +1708,21 @@ watch(selectedMetric, value => { /> Filters -

+
- -
diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts index f6a71aa86..615b29077 100644 --- a/app/composables/useSettings.ts +++ b/app/composables/useSettings.ts @@ -39,9 +39,7 @@ export interface AppSettings { animateSparkline: boolean } chartFilter: { - hampelWindow: number - hampelThreshold: number - smoothingTau: number + iqrMultiplier: number } } @@ -61,9 +59,7 @@ const DEFAULT_SETTINGS: AppSettings = { animateSparkline: true, }, chartFilter: { - hampelWindow: 4, - hampelThreshold: 2, - smoothingTau: 1, + iqrMultiplier: 1.5, }, } diff --git a/app/utils/chart-filters.ts b/app/utils/chart-filters.ts index 5a07174f5..3f3de9f53 100644 --- a/app/utils/chart-filters.ts +++ b/app/utils/chart-filters.ts @@ -1,95 +1,75 @@ +function median(sorted: number[]): number { + const mid = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[mid - 1]! + sorted[mid]!) / 2 : sorted[mid]! +} + /** - * Hampel filter: replaces outlier values with the local median. - * Uses Median Absolute Deviation (MAD) to detect outliers. + * IQR-based outlier filter. + * + * 1. Compute Q1, Q3, IQR across all values globally + * 2. Flag any point outside [Q1 - k×IQR, Q3 + k×IQR] as an outlier + * 3. Replace each outlier with the median of its non-outlier local neighbors + * + * This handles sustained anomalies (multi-week spikes) regardless of + * their position in the dataset — including at boundaries. * * @param data - array of objects with a `value` property - * @param windowSize - half-window size (0 = disabled) - * @param threshold - number of MADs above which a value is considered an outlier - * @returns a new array with outlier values replaced by the local median + * @param multiplier - IQR multiplier k (0 = disabled, standard: 1.5, less aggressive: 3) + * @param windowSize - half-window for local median replacement (default: 6) */ -export function hampelFilter( +export function iqrFilter( data: T[], - windowSize: number, - threshold: number, + multiplier: number, + windowSize: number = 6, ): T[] { - if (windowSize <= 0 || data.length === 0) return data + if (multiplier <= 0 || data.length < 4) return data + + const sorted = data.map(d => d.value).sort((a, b) => a - b) + const q1 = median(sorted.slice(0, Math.floor(sorted.length / 2))) + const q3 = median(sorted.slice(Math.ceil(sorted.length / 2))) + const iqr = q3 - q1 + + if (iqr <= 0) return data + + const lowerBound = q1 - multiplier * iqr + const upperBound = q3 + multiplier * iqr + + const isOutlier = data.map(d => d.value < lowerBound || d.value > upperBound) + if (!isOutlier.some(Boolean)) return data const result = data.map(d => ({ ...d })) const n = data.length - for (let i = 0; i < n; i++) { + for (let i = 1; i < n - 1; i++) { + if (!isOutlier[i]) continue + + // Collect non-outlier neighbors within the window const lo = Math.max(0, i - windowSize) const hi = Math.min(n - 1, i + windowSize) - - const windowValues: number[] = [] + const neighbors: number[] = [] for (let j = lo; j <= hi; j++) { - windowValues.push(data[j]!.value) + if (!isOutlier[j]) neighbors.push(data[j]!.value) } - windowValues.sort((a, b) => a - b) - - const median = windowValues[Math.floor(windowValues.length / 2)]! - const deviations = windowValues.map(v => Math.abs(v - median)).sort((a, b) => a - b) - const mad = deviations[Math.floor(deviations.length / 2)]! - - // 1.4826 converts MAD to an estimate of the standard deviation - const scaledMad = 1.4826 * mad - if (scaledMad > 0 && Math.abs(data[i]!.value - median) > threshold * scaledMad) { - result[i]!.value = median + if (neighbors.length > 0) { + neighbors.sort((a, b) => a - b) + result[i]!.value = median(neighbors) } } return result } -/** - * Low-pass (exponential smoothing) filter. - * - * @param data - array of objects with a `value` property - * @param tau - smoothing time constant (0 = disabled, higher = smoother) - * @returns a new array with smoothed values - */ -export function lowPassFilter(data: T[], tau: number): T[] { - if (tau <= 0 || data.length === 0) return data - - const result = data.map(d => ({ ...d })) - const alpha = 1 / (1 + tau) - - result[0]!.value = data[0]!.value - for (let i = 1; i < data.length; i++) { - result[i]!.value = alpha * data[i]!.value + (1 - alpha) * result[i - 1]!.value - } - - return result -} - export interface ChartFilterSettings { - hampelWindow: number - hampelThreshold: number - smoothingTau: number + iqrMultiplier: number } /** - * Applies Hampel filter then low-pass smoothing in sequence. + * Applies IQR-based outlier filter to download data. */ export function applyDownloadFilter( data: T[], settings: ChartFilterSettings, ): T[] { - if (data.length < 2) return data - - const firstValue = data[0]!.value - const lastValue = data[data.length - 1]!.value - - let result = data - result = hampelFilter(result, settings.hampelWindow, settings.hampelThreshold) - result = lowPassFilter(result, settings.smoothingTau) - - // Preserve original first and last values - if (result !== data) { - result[0]!.value = firstValue - result[result.length - 1]!.value = lastValue - } - - return result + return iqrFilter(data, settings.iqrMultiplier) } From 88b274565a8b4449faea5032e967104d5f5b60d3 Mon Sep 17 00:00:00 2001 From: jycouet Date: Wed, 25 Feb 2026 01:49:06 +0100 Subject: [PATCH 3/8] manual anomalies --- app/components/Package/TrendsChart.vue | 46 +++++-- .../Package/WeeklyDownloadStats.vue | 14 ++- app/composables/useSettings.ts | 8 +- app/utils/chart-filters.ts | 117 +++++++++++------- app/utils/download-anomalies.data.ts | 10 ++ app/utils/download-anomalies.ts | 109 ++++++++++++++++ 6 files changed, 246 insertions(+), 58 deletions(-) create mode 100644 app/utils/download-anomalies.data.ts create mode 100644 app/utils/download-anomalies.ts diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index d0d7224c0..97ea10d03 100644 --- a/app/components/Package/TrendsChart.vue +++ b/app/components/Package/TrendsChart.vue @@ -19,6 +19,7 @@ import type { } 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<{ @@ -943,6 +944,10 @@ const effectiveDataSingle = computed(() => { } 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, @@ -984,6 +989,9 @@ const chartData = computed<{ for (const pkg of names) { 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, @@ -1708,20 +1716,44 @@ watch(selectedMetric, value => { /> Filters -
-
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 615b29077..d3c3cd46e 100644 --- a/app/composables/useSettings.ts +++ b/app/composables/useSettings.ts @@ -39,7 +39,9 @@ export interface AppSettings { animateSparkline: boolean } chartFilter: { - iqrMultiplier: number + averageWindow: number + smoothingTau: number + anomaliesFixed: boolean } } @@ -59,7 +61,9 @@ const DEFAULT_SETTINGS: AppSettings = { animateSparkline: true, }, chartFilter: { - iqrMultiplier: 1.5, + averageWindow: 0, + smoothingTau: 1, + anomaliesFixed: true, }, } diff --git a/app/utils/chart-filters.ts b/app/utils/chart-filters.ts index 3f3de9f53..943c24320 100644 --- a/app/utils/chart-filters.ts +++ b/app/utils/chart-filters.ts @@ -1,75 +1,96 @@ -function median(sorted: number[]): number { - const mid = Math.floor(sorted.length / 2) - return sorted.length % 2 === 0 ? (sorted[mid - 1]! + sorted[mid]!) / 2 : sorted[mid]! -} - /** - * IQR-based outlier filter. - * - * 1. Compute Q1, Q3, IQR across all values globally - * 2. Flag any point outside [Q1 - k×IQR, Q3 + k×IQR] as an outlier - * 3. Replace each outlier with the median of its non-outlier local neighbors - * - * This handles sustained anomalies (multi-week spikes) regardless of - * their position in the dataset — including at boundaries. + * 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 data - array of objects with a `value` property - * @param multiplier - IQR multiplier k (0 = disabled, standard: 1.5, less aggressive: 3) - * @param windowSize - half-window for local median replacement (default: 6) + * @param halfWindow - number of points on each side (0 = disabled) */ -export function iqrFilter( - data: T[], - multiplier: number, - windowSize: number = 6, -): T[] { - if (multiplier <= 0 || data.length < 4) return data - - const sorted = data.map(d => d.value).sort((a, b) => a - b) - const q1 = median(sorted.slice(0, Math.floor(sorted.length / 2))) - const q3 = median(sorted.slice(Math.ceil(sorted.length / 2))) - const iqr = q3 - q1 +export function movingAverage(data: T[], halfWindow: number): T[] { + if (halfWindow <= 0 || data.length < 3) return data - if (iqr <= 0) return data + const n = data.length - const lowerBound = q1 - multiplier * iqr - const upperBound = q3 + multiplier * iqr + // 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) + } - const isOutlier = data.map(d => d.value < lowerBound || d.value > upperBound) - if (!isOutlier.some(Boolean)) return data + // 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 - for (let i = 1; i < n - 1; i++) { - if (!isOutlier[i]) continue + // 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]! + } - // Collect non-outlier neighbors within the window - const lo = Math.max(0, i - windowSize) - const hi = Math.min(n - 1, i + windowSize) - const neighbors: number[] = [] - for (let j = lo; j <= hi; j++) { - if (!isOutlier[j]) neighbors.push(data[j]!.value) - } + // 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]! + } - if (neighbors.length > 0) { - neighbors.sort((a, b) => a - b) - result[i]!.value = median(neighbors) - } + // 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 { - iqrMultiplier: number + averageWindow: number + smoothingTau: number } /** - * Applies IQR-based outlier filter to download data. + * Applies moving average then smoothing in sequence. */ export function applyDownloadFilter( data: T[], settings: ChartFilterSettings, ): T[] { - return iqrFilter(data, settings.iqrMultiplier) + 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 +} From c5faf4ccec57407f623cf4ae64c33e4d48d40b3b Mon Sep 17 00:00:00 2001 From: jycouet Date: Wed, 25 Feb 2026 02:12:28 +0100 Subject: [PATCH 4/8] $t --- app/components/Package/TrendsChart.vue | 8 ++++---- i18n/locales/en.json | 6 +++++- i18n/schema.json | 12 ++++++++++++ lunaria/files/en-GB.json | 6 +++++- lunaria/files/en-US.json | 6 +++++- 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index 97ea10d03..0d80c44db 100644 --- a/app/components/Package/TrendsChart.vue +++ b/app/components/Package/TrendsChart.vue @@ -1714,12 +1714,12 @@ watch(selectedMetric, value => { :class="showFilterControls ? 'i-lucide:chevron-down' : 'i-lucide:chevron-right'" aria-hidden="true" /> - Filters + {{ $t('package.trends.filters') }}
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", From bd98130d59999010bdb8478f39d83e4c9601c1ab Mon Sep 17 00:00:00 2001 From: Alec Lloyd Probert <55991794+graphieros@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:43:54 +0100 Subject: [PATCH 5/8] fix: add missing comma --- i18n/locales/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i18n/locales/en.json b/i18n/locales/en.json index aae2c3dc1..53f429afa 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -382,7 +382,7 @@ "filters": "Filters", "average": "Average", "smoothing": "Smoothing", - "anomalies_fixed": "Anomalies fixed" + "anomalies_fixed": "Anomalies fixed", "copy_alt": { "trend_none": "mostly flat", "trend_strong": "strong", From 5f7cbbcedd6e6b68fa6cba2066801b1f9af0c914 Mon Sep 17 00:00:00 2001 From: Alec Lloyd Probert <55991794+graphieros@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:45:06 +0100 Subject: [PATCH 6/8] fix: add missing comma --- lunaria/files/en-GB.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lunaria/files/en-GB.json b/lunaria/files/en-GB.json index f017acdfd..f054993e6 100644 --- a/lunaria/files/en-GB.json +++ b/lunaria/files/en-GB.json @@ -381,7 +381,7 @@ "filters": "Filters", "average": "Average", "smoothing": "Smoothing", - "anomalies_fixed": "Anomalies fixed" + "anomalies_fixed": "Anomalies fixed", "copy_alt": { "trend_none": "mostly flat", "trend_strong": "strong", From 1360b841fcca0c5685d8a4f3472a8e9c5ff5fca6 Mon Sep 17 00:00:00 2001 From: Alec Lloyd Probert <55991794+graphieros@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:45:48 +0100 Subject: [PATCH 7/8] fix: add missing comma --- lunaria/files/en-US.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json index 787751f8b..5cc9308e0 100644 --- a/lunaria/files/en-US.json +++ b/lunaria/files/en-US.json @@ -381,7 +381,7 @@ "filters": "Filters", "average": "Average", "smoothing": "Smoothing", - "anomalies_fixed": "Anomalies fixed" + "anomalies_fixed": "Anomalies fixed", "copy_alt": { "trend_none": "mostly flat", "trend_strong": "strong", From f597318add53b0b429d8b2602c59e34b2b306181 Mon Sep 17 00:00:00 2001 From: Alec Lloyd Probert <55991794+graphieros@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:46:37 +0100 Subject: [PATCH 8/8] fix: add missing brace & comma --- i18n/schema.json | 1 + 1 file changed, 1 insertion(+) diff --git a/i18n/schema.json b/i18n/schema.json index 04dd4b3dd..f72698b87 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1152,6 +1152,7 @@ }, "anomalies_fixed": { "type": "string" + }, "copy_alt": { "type": "object", "properties": {