Skip to content

Commit 88b2745

Browse files
committed
manual anomalies
1 parent 230585e commit 88b2745

6 files changed

Lines changed: 246 additions & 58 deletions

File tree

app/components/Package/TrendsChart.vue

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
} from '~/types/chart'
2020
import { DATE_INPUT_MAX } from '~/utils/input'
2121
import { applyDownloadFilter } from '~/utils/chart-filters'
22+
import { applyBlocklistFilter } from '~/utils/download-anomalies'
2223
2324
const props = withDefaults(
2425
defineProps<{
@@ -943,6 +944,10 @@ const effectiveDataSingle = computed<EvolutionData>(() => {
943944
}
944945
945946
if (isDownloadsMetric.value && data.length) {
947+
const pkg = effectivePackageNames.value[0] ?? props.packageName ?? ''
948+
if (settings.value.chartFilter.anomaliesFixed) {
949+
data = applyBlocklistFilter(data, pkg, displayedGranularity.value)
950+
}
946951
return applyDownloadFilter(
947952
data as Array<{ value: number }>,
948953
settings.value.chartFilter,
@@ -984,6 +989,9 @@ const chartData = computed<{
984989
for (const pkg of names) {
985990
let data = state.evolutionsByPackage[pkg] ?? []
986991
if (isDownloadsMetric.value && data.length) {
992+
if (settings.value.chartFilter.anomaliesFixed) {
993+
data = applyBlocklistFilter(data, pkg, granularity)
994+
}
987995
data = applyDownloadFilter(
988996
data as Array<{ value: number }>,
989997
settings.value.chartFilter,
@@ -1708,20 +1716,44 @@ watch(selectedMetric, value => {
17081716
/>
17091717
Filters
17101718
</button>
1711-
<div v-if="showFilterControls" class="max-w-xs">
1712-
<label class="flex flex-col gap-1">
1719+
<div v-if="showFilterControls" class="flex items-end gap-3">
1720+
<label class="flex flex-col gap-1 flex-1">
1721+
<span class="text-2xs font-mono text-fg-subtle tracking-wide uppercase">
1722+
Average
1723+
<span class="text-fg-muted">({{ settings.chartFilter.averageWindow }})</span>
1724+
</span>
1725+
<input
1726+
v-model.number="settings.chartFilter.averageWindow"
1727+
type="range"
1728+
min="0"
1729+
max="20"
1730+
step="1"
1731+
class="accent-[var(--accent-color,var(--fg-subtle))]"
1732+
/>
1733+
</label>
1734+
<label class="flex flex-col gap-1 flex-1">
17131735
<span class="text-2xs font-mono text-fg-subtle tracking-wide uppercase">
1714-
Outlier sensitivity
1715-
<span class="text-fg-muted">({{ settings.chartFilter.iqrMultiplier }})</span>
1736+
Smoothing
1737+
<span class="text-fg-muted">({{ settings.chartFilter.smoothingTau }})</span>
17161738
</span>
17171739
<input
1718-
v-model.number="settings.chartFilter.iqrMultiplier"
1740+
v-model.number="settings.chartFilter.smoothingTau"
17191741
type="range"
17201742
min="0"
1721-
max="5"
1722-
step="0.5"
1743+
max="20"
1744+
step="1"
1745+
class="accent-[var(--accent-color,var(--fg-subtle))]"
1746+
/>
1747+
</label>
1748+
<label
1749+
class="flex items-center gap-1.5 text-2xs font-mono text-fg-subtle tracking-wide uppercase cursor-pointer shrink-0 -mb-0.5"
1750+
>
1751+
<input
1752+
v-model="settings.chartFilter.anomaliesFixed"
1753+
type="checkbox"
17231754
class="accent-[var(--accent-color,var(--fg-subtle))]"
17241755
/>
1756+
Anomalies fixed
17251757
</label>
17261758
</div>
17271759
</div>

app/components/Package/WeeklyDownloadStats.vue

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
import { VueUiSparkline } from 'vue-data-ui/vue-ui-sparkline'
33
import { useCssVariables } from '~/composables/useColors'
44
import type { WeeklyDataPoint } from '~/types/chart'
5+
import { applyDownloadFilter } from '~/utils/chart-filters'
56
import { OKLCH_NEUTRAL_FALLBACK, lightenOklch } from '~/utils/colors'
7+
import { applyBlocklistFilter } from '~/utils/download-anomalies'
68
import type { RepoRef } from '#shared/utils/git-providers'
79
import type { VueUiSparklineConfig, VueUiSparklineDatasetItem } from 'vue-data-ui'
810
@@ -177,8 +179,18 @@ watch(
177179
() => loadWeeklyDownloads(),
178180
)
179181
182+
const filteredDownloads = computed<WeeklyDataPoint[]>(() => {
183+
let data = weeklyDownloads.value as WeeklyDataPoint[]
184+
if (!data.length) return data
185+
if (settings.value.chartFilter.anomaliesFixed) {
186+
data = applyBlocklistFilter(data, props.packageName, 'weekly') as WeeklyDataPoint[]
187+
}
188+
data = applyDownloadFilter(data, settings.value.chartFilter) as WeeklyDataPoint[]
189+
return data
190+
})
191+
180192
const dataset = computed<VueUiSparklineDatasetItem[]>(() =>
181-
weeklyDownloads.value.map(d => ({
193+
filteredDownloads.value.map(d => ({
182194
value: d?.value ?? 0,
183195
period: $t('package.trends.date_range', {
184196
start: d.weekStart ?? '-',

app/composables/useSettings.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ export interface AppSettings {
3939
animateSparkline: boolean
4040
}
4141
chartFilter: {
42-
iqrMultiplier: number
42+
averageWindow: number
43+
smoothingTau: number
44+
anomaliesFixed: boolean
4345
}
4446
}
4547

@@ -59,7 +61,9 @@ const DEFAULT_SETTINGS: AppSettings = {
5961
animateSparkline: true,
6062
},
6163
chartFilter: {
62-
iqrMultiplier: 1.5,
64+
averageWindow: 0,
65+
smoothingTau: 1,
66+
anomaliesFixed: true,
6367
},
6468
}
6569

app/utils/chart-filters.ts

Lines changed: 69 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,96 @@
1-
function median(sorted: number[]): number {
2-
const mid = Math.floor(sorted.length / 2)
3-
return sorted.length % 2 === 0 ? (sorted[mid - 1]! + sorted[mid]!) / 2 : sorted[mid]!
4-
}
5-
61
/**
7-
* IQR-based outlier filter.
8-
*
9-
* 1. Compute Q1, Q3, IQR across all values globally
10-
* 2. Flag any point outside [Q1 - k×IQR, Q3 + k×IQR] as an outlier
11-
* 3. Replace each outlier with the median of its non-outlier local neighbors
12-
*
13-
* This handles sustained anomalies (multi-week spikes) regardless of
14-
* their position in the dataset — including at boundaries.
2+
* Bidirectional moving average. Blends a trailing (left-anchored) and leading
3+
* (right-anchored) average by position so transitions from both fixed endpoints
4+
* are smooth.
5+
* First and last points are preserved.
156
*
16-
* @param data - array of objects with a `value` property
17-
* @param multiplier - IQR multiplier k (0 = disabled, standard: 1.5, less aggressive: 3)
18-
* @param windowSize - half-window for local median replacement (default: 6)
7+
* @param halfWindow - number of points on each side (0 = disabled)
198
*/
20-
export function iqrFilter<T extends { value: number }>(
21-
data: T[],
22-
multiplier: number,
23-
windowSize: number = 6,
24-
): T[] {
25-
if (multiplier <= 0 || data.length < 4) return data
26-
27-
const sorted = data.map(d => d.value).sort((a, b) => a - b)
28-
const q1 = median(sorted.slice(0, Math.floor(sorted.length / 2)))
29-
const q3 = median(sorted.slice(Math.ceil(sorted.length / 2)))
30-
const iqr = q3 - q1
9+
export function movingAverage<T extends { value: number }>(data: T[], halfWindow: number): T[] {
10+
if (halfWindow <= 0 || data.length < 3) return data
3111

32-
if (iqr <= 0) return data
12+
const n = data.length
3313

34-
const lowerBound = q1 - multiplier * iqr
35-
const upperBound = q3 + multiplier * iqr
14+
// Trailing average (anchored to start): average of [max(0, i-halfWindow), i]
15+
const trailing: number[] = Array.from({ length: n })
16+
for (let i = 0; i < n; i++) {
17+
const lo = Math.max(0, i - halfWindow)
18+
let sum = 0
19+
for (let j = lo; j <= i; j++) sum += data[j]!.value
20+
trailing[i] = sum / (i - lo + 1)
21+
}
3622

37-
const isOutlier = data.map(d => d.value < lowerBound || d.value > upperBound)
38-
if (!isOutlier.some(Boolean)) return data
23+
// Leading average (anchored to end): average of [i, min(n-1, i+halfWindow)]
24+
const leading: number[] = Array.from({ length: n })
25+
for (let i = 0; i < n; i++) {
26+
const hi = Math.min(n - 1, i + halfWindow)
27+
let sum = 0
28+
for (let j = i; j <= hi; j++) sum += data[j]!.value
29+
leading[i] = sum / (hi - i + 1)
30+
}
3931

32+
// Position-based blend: near start → mostly trailing, near end → mostly leading
4033
const result = data.map(d => ({ ...d }))
34+
for (let i = 1; i < n - 1; i++) {
35+
const t = i / (n - 1)
36+
result[i]!.value = (1 - t) * trailing[i]! + t * leading[i]!
37+
}
38+
39+
return result
40+
}
41+
42+
/**
43+
* Forward-backward exponential smoothing (zero-phase).
44+
* Smooths without introducing lag — preserves the dynamics/timing of trends.
45+
* First and last points are preserved.
46+
*
47+
* @param tau - time constant (0 = disabled, higher = smoother)
48+
*/
49+
export function smoothing<T extends { value: number }>(data: T[], tau: number): T[] {
50+
if (tau <= 0 || data.length < 3) return data
51+
52+
const alpha = 1 / (1 + tau)
4153
const n = data.length
4254

43-
for (let i = 1; i < n - 1; i++) {
44-
if (!isOutlier[i]) continue
55+
// Forward pass
56+
const forward: number[] = Array.from({ length: n })
57+
forward[0] = data[0]!.value
58+
for (let i = 1; i < n; i++) {
59+
forward[i] = alpha * data[i]!.value + (1 - alpha) * forward[i - 1]!
60+
}
4561

46-
// Collect non-outlier neighbors within the window
47-
const lo = Math.max(0, i - windowSize)
48-
const hi = Math.min(n - 1, i + windowSize)
49-
const neighbors: number[] = []
50-
for (let j = lo; j <= hi; j++) {
51-
if (!isOutlier[j]) neighbors.push(data[j]!.value)
52-
}
62+
// Backward pass
63+
const backward: number[] = Array.from({ length: n })
64+
backward[n - 1] = data[n - 1]!.value
65+
for (let i = n - 2; i >= 0; i--) {
66+
backward[i] = alpha * data[i]!.value + (1 - alpha) * backward[i + 1]!
67+
}
5368

54-
if (neighbors.length > 0) {
55-
neighbors.sort((a, b) => a - b)
56-
result[i]!.value = median(neighbors)
57-
}
69+
// Position-based blend: near start → mostly forward, near end → mostly backward
70+
// This ensures smooth transitions from both fixed endpoints
71+
const result = data.map(d => ({ ...d }))
72+
for (let i = 1; i < n - 1; i++) {
73+
const t = i / (n - 1)
74+
result[i]!.value = (1 - t) * forward[i]! + t * backward[i]!
5875
}
5976

6077
return result
6178
}
6279

6380
export interface ChartFilterSettings {
64-
iqrMultiplier: number
81+
averageWindow: number
82+
smoothingTau: number
6583
}
6684

6785
/**
68-
* Applies IQR-based outlier filter to download data.
86+
* Applies moving average then smoothing in sequence.
6987
*/
7088
export function applyDownloadFilter<T extends { value: number }>(
7189
data: T[],
7290
settings: ChartFilterSettings,
7391
): T[] {
74-
return iqrFilter(data, settings.iqrMultiplier)
92+
let result = data
93+
result = movingAverage(result, settings.averageWindow)
94+
result = smoothing(result, settings.smoothingTau)
95+
return result
7596
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { DownloadAnomaly } from './download-anomalies'
2+
3+
export const DOWNLOAD_ANOMALIES: DownloadAnomaly[] = [
4+
// vite rogue CI spike
5+
{
6+
packageName: 'vite',
7+
start: { date: '2025-08-04', weeklyDownloads: 33_913_132 },
8+
end: { date: '2025-09-08', weeklyDownloads: 38_665_727 },
9+
},
10+
]

app/utils/download-anomalies.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type { ChartTimeGranularity, EvolutionData } from '~/types/chart'
2+
import { DOWNLOAD_ANOMALIES } from './download-anomalies.data'
3+
4+
export type DownloadAnomalyBound = {
5+
date: string // YYYY-MM-DD
6+
weeklyDownloads: number
7+
}
8+
9+
export type DownloadAnomaly = {
10+
packageName: string
11+
start: DownloadAnomalyBound
12+
end: DownloadAnomalyBound
13+
}
14+
15+
function getDateString(point: Record<string, any>, granularity: ChartTimeGranularity): string {
16+
switch (granularity) {
17+
case 'daily':
18+
return point.day
19+
case 'weekly':
20+
return point.weekStart
21+
case 'monthly':
22+
return `${point.month}-01`
23+
case 'yearly':
24+
return `${point.year}-01-01`
25+
}
26+
}
27+
28+
/**
29+
* For daily/weekly the point date falls strictly between the anomaly bounds.
30+
* For monthly/yearly the anomaly bounds are truncated to the same resolution
31+
* so that any period overlapping the anomaly is caught (inclusive).
32+
*/
33+
function isDateAffected(
34+
date: string,
35+
anomaly: DownloadAnomaly,
36+
granularity: ChartTimeGranularity,
37+
): boolean {
38+
switch (granularity) {
39+
case 'daily':
40+
case 'weekly':
41+
return date > anomaly.start.date && date < anomaly.end.date
42+
case 'monthly': {
43+
const startMonth = anomaly.start.date.slice(0, 7) + '-01'
44+
const endMonth = anomaly.end.date.slice(0, 7) + '-01'
45+
return date >= startMonth && date <= endMonth
46+
}
47+
case 'yearly': {
48+
const startYear = anomaly.start.date.slice(0, 4) + '-01-01'
49+
const endYear = anomaly.end.date.slice(0, 4) + '-01-01'
50+
return date >= startYear && date <= endYear
51+
}
52+
}
53+
}
54+
55+
function scaleWeeklyValue(weeklyValue: number, granularity: ChartTimeGranularity): number {
56+
switch (granularity) {
57+
case 'daily':
58+
return Math.round(weeklyValue / 7)
59+
case 'weekly':
60+
return weeklyValue
61+
case 'monthly':
62+
return Math.round((weeklyValue / 7) * 30)
63+
case 'yearly':
64+
return Math.round((weeklyValue / 7) * 365)
65+
}
66+
}
67+
68+
export function applyBlocklistFilter(
69+
data: EvolutionData,
70+
packageName: string,
71+
granularity: ChartTimeGranularity,
72+
): EvolutionData {
73+
const anomalies = DOWNLOAD_ANOMALIES.filter(a => a.packageName === packageName)
74+
if (!anomalies.length) return data
75+
76+
// Clone to avoid mutation
77+
const result = (data as Array<Record<string, any>>).map(d => ({ ...d }))
78+
79+
for (const anomaly of anomalies) {
80+
// Find indices of affected points
81+
const affectedIndices: number[] = []
82+
for (let i = 0; i < result.length; i++) {
83+
const date = getDateString(result[i]!, granularity)
84+
if (isDateAffected(date, anomaly, granularity)) {
85+
affectedIndices.push(i)
86+
}
87+
}
88+
89+
if (!affectedIndices.length) continue
90+
91+
const firstAffected = affectedIndices[0]!
92+
const lastAffected = affectedIndices[affectedIndices.length - 1]!
93+
94+
// Use neighbors when available, fall back to scaled weeklyDownloads
95+
const scaledStart = scaleWeeklyValue(anomaly.start.weeklyDownloads, granularity)
96+
const scaledEnd = scaleWeeklyValue(anomaly.end.weeklyDownloads, granularity)
97+
98+
const startVal = firstAffected > 0 ? result[firstAffected - 1]!.value : scaledStart
99+
const endVal = lastAffected < result.length - 1 ? result[lastAffected + 1]!.value : scaledEnd
100+
101+
const count = affectedIndices.length
102+
for (let i = 0; i < count; i++) {
103+
const t = (i + 1) / (count + 1)
104+
result[affectedIndices[i]!]!.value = Math.round(startVal + t * (endVal - startVal))
105+
}
106+
}
107+
108+
return result as EvolutionData
109+
}

0 commit comments

Comments
 (0)