Skip to content
Merged
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
168 changes: 164 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 { applyDataCorrection } from '~/utils/chart-data-correction'
import { applyBlocklistCorrection, getAnomaliesForPackages } from '~/utils/download-anomalies'
import { copyAltTextForTrendLineChart } from '~/utils/charts'
const props = withDefaults(
Expand Down Expand Up @@ -51,6 +53,7 @@ const props = withDefaults(
const { locale } = useI18n()
const { accentColors, selectedAccentColor } = useAccentColor()
const { settings } = useSettings()
const { copy, copied } = useClipboard()
const colorMode = useColorMode()
Expand Down Expand Up @@ -929,15 +932,35 @@ 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
}
return state.evolution
if (isDownloadsMetric.value && data.length) {
const pkg = effectivePackageNames.value[0] ?? props.packageName ?? ''
if (settings.value.chartFilter.anomaliesFixed) {
data = applyBlocklistCorrection({
data,
packageName: pkg,
granularity: displayedGranularity.value,
})
}
return applyDataCorrection(
data as Array<{ value: number }>,
settings.value.chartFilter,
) as EvolutionData
}
return data
})
/**
Expand Down Expand Up @@ -971,7 +994,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 = applyBlocklistCorrection({ data, packageName: pkg, granularity })
}
data = applyDataCorrection(
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 @@ -1598,6 +1630,23 @@ const chartConfig = computed<VueUiXyConfig>(() => {
}
})
const isDownloadsMetric = computed(() => selectedMetric.value === 'downloads')
const showCorrectionControls = shallowRef(false)
const packageAnomalies = computed(() => getAnomaliesForPackages(effectivePackageNames.value))
const hasAnomalies = computed(() => packageAnomalies.value.length > 0)
function formatAnomalyDate(dateStr: string) {
const [y, m, d] = dateStr.split('-').map(Number)
if (!y || !m || !d) return dateStr
return new Intl.DateTimeFormat(locale.value, {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
}).format(new Date(Date.UTC(y, m - 1, d)))
}
// Trigger data loading when the metric is switched
watch(selectedMetric, value => {
if (!isMounted.value) return
Expand Down Expand Up @@ -1686,6 +1735,117 @@ 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="showCorrectionControls = !showCorrectionControls"
>
<span
class="w-3.5 h-3.5 transition-transform"
:class="showCorrectionControls ? 'i-lucide:chevron-down' : 'i-lucide:chevron-right'"
aria-hidden="true"
/>
{{ $t('package.trends.data_correction') }}
</button>
<div v-if="showCorrectionControls" 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_window') }}
<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>
<div class="flex flex-col gap-1 shrink-0">
<span
class="text-2xs font-mono text-fg-subtle tracking-wide uppercase flex items-center justify-between"
>
{{ $t('package.trends.known_anomalies') }}
<TooltipApp interactive :to="inModal ? '#chart-modal' : undefined">
<button
type="button"
class="i-lucide:info w-3.5 h-3.5 text-fg-muted cursor-help"
:aria-label="$t('package.trends.known_anomalies')"
/>
<template #content>
<div class="flex flex-col gap-3">
<p class="text-xs text-fg-muted">
{{ $t('package.trends.known_anomalies_description') }}
</p>
<div v-if="hasAnomalies">
<p class="text-xs text-fg-subtle font-medium">
{{ $t('package.trends.known_anomalies_ranges') }}
</p>
<ul class="text-xs text-fg-subtle list-disc list-inside">
<li v-for="a in packageAnomalies" :key="`${a.packageName}-${a.start}`">
{{
isMultiPackageMode
? $t('package.trends.known_anomalies_range_named', {
packageName: a.packageName,
start: formatAnomalyDate(a.start),
end: formatAnomalyDate(a.end),
})
: $t('package.trends.known_anomalies_range', {
start: formatAnomalyDate(a.start),
end: formatAnomalyDate(a.end),
})
}}
</li>
</ul>
</div>
<p v-else class="text-xs text-fg-muted">
{{ $t('package.trends.known_anomalies_none', effectivePackageNames.length) }}
</p>
<div class="flex justify-end">
<LinkBase
to="https://github.com/npmx-dev/npmx.dev/edit/main/app/utils/download-anomalies.data.ts"
class="text-xs text-accent"
>
{{ $t('package.trends.known_anomalies_contribute') }}
</LinkBase>
</div>
</div>
</template>
</TooltipApp>
</span>
<label
class="flex items-center gap-1.5 text-2xs font-mono text-fg-subtle cursor-pointer"
:class="{ 'opacity-50 pointer-events-none': !hasAnomalies }"
>
<input
v-model="settings.chartFilter.anomaliesFixed"
type="checkbox"
:disabled="!hasAnomalies"
class="accent-[var(--accent-color,var(--fg-subtle))]"
/>
{{ $t('package.trends.apply_correction') }}
</label>
</div>
</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
18 changes: 17 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 { applyDataCorrection } from '~/utils/chart-data-correction'
import { OKLCH_NEUTRAL_FALLBACK, lightenOklch } from '~/utils/colors'
import { applyBlocklistCorrection } 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,22 @@ watch(
() => loadWeeklyDownloads(),
)
const correctedDownloads = computed<WeeklyDataPoint[]>(() => {
let data = weeklyDownloads.value as WeeklyDataPoint[]
if (!data.length) return data
if (settings.value.chartFilter.anomaliesFixed) {
data = applyBlocklistCorrection({
data,
packageName: props.packageName,
granularity: 'weekly',
}) as WeeklyDataPoint[]
}
data = applyDataCorrection(data, settings.value.chartFilter) as WeeklyDataPoint[]
return data
})
const dataset = computed<VueUiSparklineDatasetItem[]>(() =>
weeklyDownloads.value.map(d => ({
correctedDownloads.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-data-correction.ts
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 applyDataCorrection<T extends { value: number }>(
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