Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 91 additions & 4 deletions app/components/Package/TrendsChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down Expand Up @@ -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<HTMLElement | null>(null)
Expand Down Expand Up @@ -926,15 +929,31 @@ watch(

const effectiveDataSingle = computed<EvolutionData>(() => {
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
})

/**
Expand Down Expand Up @@ -968,7 +987,16 @@ const chartData = computed<{
const pointsByPackage = new Map<string, Array<{ timestamp: number; value: number }>>()

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)
Expand Down Expand Up @@ -1583,6 +1611,9 @@ const chartConfig = computed<VueUiXyConfig>(() => {
}
})

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
Expand Down Expand Up @@ -1671,6 +1702,62 @@ watch(selectedMetric, value => {
</button>
</div>

<!-- Download filter controls -->
<div v-if="isDownloadsMetric" class="flex flex-col gap-2">
<button
type="button"
class="self-start flex items-center gap-1 text-2xs font-mono text-fg-subtle hover:text-fg transition-colors"
@click="showFilterControls = !showFilterControls"
>
<span
class="w-3.5 h-3.5 transition-transform"
:class="showFilterControls ? 'i-lucide:chevron-down' : 'i-lucide:chevron-right'"
aria-hidden="true"
/>
{{ $t('package.trends.filters') }}
</button>
<div v-if="showFilterControls" class="flex items-end gap-3">
<label class="flex flex-col gap-1 flex-1">
<span class="text-2xs font-mono text-fg-subtle tracking-wide uppercase">
{{ $t('package.trends.average') }}
<span class="text-fg-muted">({{ settings.chartFilter.averageWindow }})</span>
</span>
<input
v-model.number="settings.chartFilter.averageWindow"
type="range"
min="0"
max="20"
step="1"
class="accent-[var(--accent-color,var(--fg-subtle))]"
/>
</label>
<label class="flex flex-col gap-1 flex-1">
<span class="text-2xs font-mono text-fg-subtle tracking-wide uppercase">
{{ $t('package.trends.smoothing') }}
<span class="text-fg-muted">({{ settings.chartFilter.smoothingTau }})</span>
</span>
<input
v-model.number="settings.chartFilter.smoothingTau"
type="range"
min="0"
max="20"
step="1"
class="accent-[var(--accent-color,var(--fg-subtle))]"
/>
</label>
<label
class="flex items-center gap-1.5 text-2xs font-mono text-fg-subtle tracking-wide uppercase cursor-pointer shrink-0 -mb-0.5"
>
<input
v-model="settings.chartFilter.anomaliesFixed"
type="checkbox"
class="accent-[var(--accent-color,var(--fg-subtle))]"
/>
{{ $t('package.trends.anomalies_fixed') }}
</label>
</div>
</div>

<p v-if="skippedPackagesWithoutGitHub.length > 0" class="text-2xs font-mono text-fg-subtle">
{{ $t('package.trends.contributors_skip', { count: skippedPackagesWithoutGitHub.length }) }}
{{ skippedPackagesWithoutGitHub.join(', ') }}
Expand Down
14 changes: 13 additions & 1 deletion app/components/Package/WeeklyDownloadStats.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -177,8 +179,18 @@ watch(
() => loadWeeklyDownloads(),
)

const filteredDownloads = computed<WeeklyDataPoint[]>(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest renaming this var to something like correctedDownloads

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<VueUiSparklineDatasetItem[]>(() =>
weeklyDownloads.value.map(d => ({
filteredDownloads.value.map(d => ({
value: d?.value ?? 0,
period: $t('package.trends.date_range', {
start: d.weekStart ?? '-',
Expand Down
10 changes: 10 additions & 0 deletions app/composables/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ export interface AppSettings {
collapsed: string[]
animateSparkline: boolean
}
chartFilter: {
averageWindow: number
smoothingTau: number
anomaliesFixed: boolean
}
}

const DEFAULT_SETTINGS: AppSettings = {
Expand All @@ -55,6 +60,11 @@ const DEFAULT_SETTINGS: AppSettings = {
collapsed: [],
animateSparkline: true,
},
chartFilter: {
averageWindow: 0,
smoothingTau: 1,
anomaliesFixed: true,
},
}

const STORAGE_KEY = 'npmx-settings'
Expand Down
96 changes: 96 additions & 0 deletions app/utils/chart-filters.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest renaming this file to something like 'chart-data-correction'

Original file line number Diff line number Diff line change
@@ -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<T extends { value: number }>(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<T extends { value: number }>(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<T extends { value: number }>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest renaming this function to something like applyDataCorrection

data: T[],
settings: ChartFilterSettings,
): T[] {
let result = data
result = movingAverage(result, settings.averageWindow)
result = smoothing(result, settings.smoothingTau)
return result
}
10 changes: 10 additions & 0 deletions app/utils/download-anomalies.data.ts
Original file line number Diff line number Diff line change
@@ -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 },
},
]
Loading
Loading