Skip to content

Commit b189a1e

Browse files
committed
hampel
1 parent c52d259 commit b189a1e

3 files changed

Lines changed: 192 additions & 4 deletions

File tree

app/components/Package/TrendsChart.vue

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {
1818
YearlyDataPoint,
1919
} from '~/types/chart'
2020
import { DATE_INPUT_MAX } from '~/utils/input'
21+
import { applyDownloadFilter } from '~/utils/chart-filters'
2122
2223
const props = withDefaults(
2324
defineProps<{
@@ -50,6 +51,7 @@ const props = withDefaults(
5051
5152
const { locale } = useI18n()
5253
const { accentColors, selectedAccentColor } = useAccentColor()
54+
const { settings } = useSettings()
5355
const colorMode = useColorMode()
5456
const resolvedMode = shallowRef<'light' | 'dark'>('light')
5557
const rootEl = shallowRef<HTMLElement | null>(null)
@@ -926,15 +928,27 @@ watch(
926928
927929
const effectiveDataSingle = computed<EvolutionData>(() => {
928930
const state = activeMetricState.value
931+
let data: EvolutionData
929932
if (
930933
selectedMetric.value === DEFAULT_METRIC_ID &&
931934
displayedGranularity.value === DEFAULT_GRANULARITY &&
932935
props.weeklyDownloads?.length
933936
) {
934-
if (isWeeklyDataset(state.evolution) && state.evolution.length) return state.evolution
935-
return props.weeklyDownloads
937+
data =
938+
isWeeklyDataset(state.evolution) && state.evolution.length
939+
? state.evolution
940+
: props.weeklyDownloads
941+
} else {
942+
data = state.evolution
943+
}
944+
945+
if (isDownloadsMetric.value && data.length) {
946+
return applyDownloadFilter(
947+
data as Array<{ value: number }>,
948+
settings.value.chartFilter,
949+
) as EvolutionData
936950
}
937-
return state.evolution
951+
return data
938952
})
939953
940954
/**
@@ -968,7 +982,13 @@ const chartData = computed<{
968982
const pointsByPackage = new Map<string, Array<{ timestamp: number; value: number }>>()
969983
970984
for (const pkg of names) {
971-
const data = state.evolutionsByPackage[pkg] ?? []
985+
let data = state.evolutionsByPackage[pkg] ?? []
986+
if (isDownloadsMetric.value && data.length) {
987+
data = applyDownloadFilter(
988+
data as Array<{ value: number }>,
989+
settings.value.chartFilter,
990+
) as EvolutionData
991+
}
972992
const points = extractSeriesPoints(granularity, data)
973993
pointsByPackage.set(pkg, points)
974994
for (const p of points) timestampSet.add(p.timestamp)
@@ -1583,6 +1603,9 @@ const chartConfig = computed<VueUiXyConfig>(() => {
15831603
}
15841604
})
15851605
1606+
const isDownloadsMetric = computed(() => selectedMetric.value === 'downloads')
1607+
const showFilterControls = shallowRef(false)
1608+
15861609
// Trigger data loading when the metric is switched
15871610
watch(selectedMetric, value => {
15881611
if (!isMounted.value) return
@@ -1671,6 +1694,66 @@ watch(selectedMetric, value => {
16711694
</button>
16721695
</div>
16731696

1697+
<!-- Download filter controls -->
1698+
<div v-if="isDownloadsMetric" class="flex flex-col gap-2">
1699+
<button
1700+
type="button"
1701+
class="self-start flex items-center gap-1 text-2xs font-mono text-fg-subtle hover:text-fg transition-colors"
1702+
@click="showFilterControls = !showFilterControls"
1703+
>
1704+
<span
1705+
class="w-3.5 h-3.5 transition-transform"
1706+
:class="showFilterControls ? 'i-lucide:chevron-down' : 'i-lucide:chevron-right'"
1707+
aria-hidden="true"
1708+
/>
1709+
Filters
1710+
</button>
1711+
<div v-if="showFilterControls" class="grid grid-cols-1 sm:grid-cols-3 gap-3">
1712+
<label class="flex flex-col gap-1">
1713+
<span class="text-2xs font-mono text-fg-subtle tracking-wide uppercase">
1714+
Hampel window
1715+
<span class="text-fg-muted">({{ settings.chartFilter.hampelWindow }})</span>
1716+
</span>
1717+
<input
1718+
v-model.number="settings.chartFilter.hampelWindow"
1719+
type="range"
1720+
min="0"
1721+
max="13"
1722+
step="1"
1723+
class="accent-[var(--accent-color,var(--fg-subtle))]"
1724+
/>
1725+
</label>
1726+
<label class="flex flex-col gap-1">
1727+
<span class="text-2xs font-mono text-fg-subtle tracking-wide uppercase">
1728+
Outlier threshold
1729+
<span class="text-fg-muted">({{ settings.chartFilter.hampelThreshold }})</span>
1730+
</span>
1731+
<input
1732+
v-model.number="settings.chartFilter.hampelThreshold"
1733+
type="range"
1734+
min="0"
1735+
max="10"
1736+
step="0.5"
1737+
class="accent-[var(--accent-color,var(--fg-subtle))]"
1738+
/>
1739+
</label>
1740+
<label class="flex flex-col gap-1">
1741+
<span class="text-2xs font-mono text-fg-subtle tracking-wide uppercase">
1742+
Smoothing (tau)
1743+
<span class="text-fg-muted">({{ settings.chartFilter.smoothingTau }})</span>
1744+
</span>
1745+
<input
1746+
v-model.number="settings.chartFilter.smoothingTau"
1747+
type="range"
1748+
min="0"
1749+
max="26"
1750+
step="1"
1751+
class="accent-[var(--accent-color,var(--fg-subtle))]"
1752+
/>
1753+
</label>
1754+
</div>
1755+
</div>
1756+
16741757
<p v-if="skippedPackagesWithoutGitHub.length > 0" class="text-2xs font-mono text-fg-subtle">
16751758
{{ $t('package.trends.contributors_skip', { count: skippedPackagesWithoutGitHub.length }) }}
16761759
{{ skippedPackagesWithoutGitHub.join(', ') }}

app/composables/useSettings.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ export interface AppSettings {
3838
collapsed: string[]
3939
animateSparkline: boolean
4040
}
41+
chartFilter: {
42+
hampelWindow: number
43+
hampelThreshold: number
44+
smoothingTau: number
45+
}
4146
}
4247

4348
const DEFAULT_SETTINGS: AppSettings = {
@@ -55,6 +60,11 @@ const DEFAULT_SETTINGS: AppSettings = {
5560
collapsed: [],
5661
animateSparkline: true,
5762
},
63+
chartFilter: {
64+
hampelWindow: 4,
65+
hampelThreshold: 2,
66+
smoothingTau: 1,
67+
},
5868
}
5969

6070
const STORAGE_KEY = 'npmx-settings'

app/utils/chart-filters.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* Hampel filter: replaces outlier values with the local median.
3+
* Uses Median Absolute Deviation (MAD) to detect outliers.
4+
*
5+
* @param data - array of objects with a `value` property
6+
* @param windowSize - half-window size (0 = disabled)
7+
* @param threshold - number of MADs above which a value is considered an outlier
8+
* @returns a new array with outlier values replaced by the local median
9+
*/
10+
export function hampelFilter<T extends { value: number }>(
11+
data: T[],
12+
windowSize: number,
13+
threshold: number,
14+
): T[] {
15+
if (windowSize <= 0 || data.length === 0) return data
16+
17+
const result = data.map(d => ({ ...d }))
18+
const n = data.length
19+
20+
for (let i = 0; i < n; i++) {
21+
const lo = Math.max(0, i - windowSize)
22+
const hi = Math.min(n - 1, i + windowSize)
23+
24+
const windowValues: number[] = []
25+
for (let j = lo; j <= hi; j++) {
26+
windowValues.push(data[j]!.value)
27+
}
28+
windowValues.sort((a, b) => a - b)
29+
30+
const median = windowValues[Math.floor(windowValues.length / 2)]!
31+
const deviations = windowValues.map(v => Math.abs(v - median)).sort((a, b) => a - b)
32+
const mad = deviations[Math.floor(deviations.length / 2)]!
33+
34+
// 1.4826 converts MAD to an estimate of the standard deviation
35+
const scaledMad = 1.4826 * mad
36+
37+
if (scaledMad > 0 && Math.abs(data[i]!.value - median) > threshold * scaledMad) {
38+
result[i]!.value = median
39+
}
40+
}
41+
42+
return result
43+
}
44+
45+
/**
46+
* Low-pass (exponential smoothing) filter.
47+
*
48+
* @param data - array of objects with a `value` property
49+
* @param tau - smoothing time constant (0 = disabled, higher = smoother)
50+
* @returns a new array with smoothed values
51+
*/
52+
export function lowPassFilter<T extends { value: number }>(data: T[], tau: number): T[] {
53+
if (tau <= 0 || data.length === 0) return data
54+
55+
const result = data.map(d => ({ ...d }))
56+
const alpha = 1 / (1 + tau)
57+
58+
result[0]!.value = data[0]!.value
59+
for (let i = 1; i < data.length; i++) {
60+
result[i]!.value = alpha * data[i]!.value + (1 - alpha) * result[i - 1]!.value
61+
}
62+
63+
return result
64+
}
65+
66+
export interface ChartFilterSettings {
67+
hampelWindow: number
68+
hampelThreshold: number
69+
smoothingTau: number
70+
}
71+
72+
/**
73+
* Applies Hampel filter then low-pass smoothing in sequence.
74+
*/
75+
export function applyDownloadFilter<T extends { value: number }>(
76+
data: T[],
77+
settings: ChartFilterSettings,
78+
): T[] {
79+
if (data.length < 2) return data
80+
81+
const firstValue = data[0]!.value
82+
const lastValue = data[data.length - 1]!.value
83+
84+
let result = data
85+
result = hampelFilter(result, settings.hampelWindow, settings.hampelThreshold)
86+
result = lowPassFilter(result, settings.smoothingTau)
87+
88+
// Preserve original first and last values
89+
if (result !== data) {
90+
result[0]!.value = firstValue
91+
result[result.length - 1]!.value = lastValue
92+
}
93+
94+
return result
95+
}

0 commit comments

Comments
 (0)