Skip to content

Commit f83f22d

Browse files
authored
Merge branch 'main' into feat/graphs-play
2 parents 533839e + 790caf9 commit f83f22d

10 files changed

Lines changed: 483 additions & 5 deletions

File tree

app/components/Package/TrendsChart.vue

Lines changed: 164 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import type {
1818
YearlyDataPoint,
1919
} from '~/types/chart'
2020
import { DATE_INPUT_MAX } from '~/utils/input'
21+
import { applyDataCorrection } from '~/utils/chart-data-correction'
22+
import { applyBlocklistCorrection, getAnomaliesForPackages } from '~/utils/download-anomalies'
2123
import { copyAltTextForTrendLineChart } from '~/utils/charts'
2224
2325
const props = withDefaults(
@@ -51,6 +53,7 @@ const props = withDefaults(
5153
5254
const { locale } = useI18n()
5355
const { accentColors, selectedAccentColor } = useAccentColor()
56+
const { settings } = useSettings()
5457
const { copy, copied } = useClipboard()
5558
5659
const colorMode = useColorMode()
@@ -929,15 +932,35 @@ watch(
929932
930933
const effectiveDataSingle = computed<EvolutionData>(() => {
931934
const state = activeMetricState.value
935+
let data: EvolutionData
932936
if (
933937
selectedMetric.value === DEFAULT_METRIC_ID &&
934938
displayedGranularity.value === DEFAULT_GRANULARITY &&
935939
props.weeklyDownloads?.length
936940
) {
937-
if (isWeeklyDataset(state.evolution) && state.evolution.length) return state.evolution
938-
return props.weeklyDownloads
941+
data =
942+
isWeeklyDataset(state.evolution) && state.evolution.length
943+
? state.evolution
944+
: props.weeklyDownloads
945+
} else {
946+
data = state.evolution
939947
}
940-
return state.evolution
948+
949+
if (isDownloadsMetric.value && data.length) {
950+
const pkg = effectivePackageNames.value[0] ?? props.packageName ?? ''
951+
if (settings.value.chartFilter.anomaliesFixed) {
952+
data = applyBlocklistCorrection({
953+
data,
954+
packageName: pkg,
955+
granularity: displayedGranularity.value,
956+
})
957+
}
958+
return applyDataCorrection(
959+
data as Array<{ value: number }>,
960+
settings.value.chartFilter,
961+
) as EvolutionData
962+
}
963+
return data
941964
})
942965
943966
/**
@@ -971,7 +994,16 @@ const chartData = computed<{
971994
const pointsByPackage = new Map<string, Array<{ timestamp: number; value: number }>>()
972995
973996
for (const pkg of names) {
974-
const data = state.evolutionsByPackage[pkg] ?? []
997+
let data = state.evolutionsByPackage[pkg] ?? []
998+
if (isDownloadsMetric.value && data.length) {
999+
if (settings.value.chartFilter.anomaliesFixed) {
1000+
data = applyBlocklistCorrection({ data, packageName: pkg, granularity })
1001+
}
1002+
data = applyDataCorrection(
1003+
data as Array<{ value: number }>,
1004+
settings.value.chartFilter,
1005+
) as EvolutionData
1006+
}
9751007
const points = extractSeriesPoints(granularity, data)
9761008
pointsByPackage.set(pkg, points)
9771009
for (const p of points) timestampSet.add(p.timestamp)
@@ -1598,6 +1630,23 @@ const chartConfig = computed<VueUiXyConfig>(() => {
15981630
}
15991631
})
16001632
1633+
const isDownloadsMetric = computed(() => selectedMetric.value === 'downloads')
1634+
const showCorrectionControls = shallowRef(false)
1635+
1636+
const packageAnomalies = computed(() => getAnomaliesForPackages(effectivePackageNames.value))
1637+
const hasAnomalies = computed(() => packageAnomalies.value.length > 0)
1638+
1639+
function formatAnomalyDate(dateStr: string) {
1640+
const [y, m, d] = dateStr.split('-').map(Number)
1641+
if (!y || !m || !d) return dateStr
1642+
return new Intl.DateTimeFormat(locale.value, {
1643+
year: 'numeric',
1644+
month: 'short',
1645+
day: 'numeric',
1646+
timeZone: 'UTC',
1647+
}).format(new Date(Date.UTC(y, m - 1, d)))
1648+
}
1649+
16011650
// Trigger data loading when the metric is switched
16021651
watch(selectedMetric, value => {
16031652
if (!isMounted.value) return
@@ -1686,6 +1735,117 @@ watch(selectedMetric, value => {
16861735
</button>
16871736
</div>
16881737

1738+
<!-- Download filter controls -->
1739+
<div v-if="isDownloadsMetric" class="flex flex-col gap-2">
1740+
<button
1741+
type="button"
1742+
class="self-start flex items-center gap-1 text-2xs font-mono text-fg-subtle hover:text-fg transition-colors"
1743+
@click="showCorrectionControls = !showCorrectionControls"
1744+
>
1745+
<span
1746+
class="w-3.5 h-3.5 transition-transform"
1747+
:class="showCorrectionControls ? 'i-lucide:chevron-down' : 'i-lucide:chevron-right'"
1748+
aria-hidden="true"
1749+
/>
1750+
{{ $t('package.trends.data_correction') }}
1751+
</button>
1752+
<div v-if="showCorrectionControls" class="flex items-end gap-3">
1753+
<label class="flex flex-col gap-1 flex-1">
1754+
<span class="text-2xs font-mono text-fg-subtle tracking-wide uppercase">
1755+
{{ $t('package.trends.average_window') }}
1756+
<span class="text-fg-muted">({{ settings.chartFilter.averageWindow }})</span>
1757+
</span>
1758+
<input
1759+
v-model.number="settings.chartFilter.averageWindow"
1760+
type="range"
1761+
min="0"
1762+
max="20"
1763+
step="1"
1764+
class="accent-[var(--accent-color,var(--fg-subtle))]"
1765+
/>
1766+
</label>
1767+
<label class="flex flex-col gap-1 flex-1">
1768+
<span class="text-2xs font-mono text-fg-subtle tracking-wide uppercase">
1769+
{{ $t('package.trends.smoothing') }}
1770+
<span class="text-fg-muted">({{ settings.chartFilter.smoothingTau }})</span>
1771+
</span>
1772+
<input
1773+
v-model.number="settings.chartFilter.smoothingTau"
1774+
type="range"
1775+
min="0"
1776+
max="20"
1777+
step="1"
1778+
class="accent-[var(--accent-color,var(--fg-subtle))]"
1779+
/>
1780+
</label>
1781+
<div class="flex flex-col gap-1 shrink-0">
1782+
<span
1783+
class="text-2xs font-mono text-fg-subtle tracking-wide uppercase flex items-center justify-between"
1784+
>
1785+
{{ $t('package.trends.known_anomalies') }}
1786+
<TooltipApp interactive :to="inModal ? '#chart-modal' : undefined">
1787+
<button
1788+
type="button"
1789+
class="i-lucide:info w-3.5 h-3.5 text-fg-muted cursor-help"
1790+
:aria-label="$t('package.trends.known_anomalies')"
1791+
/>
1792+
<template #content>
1793+
<div class="flex flex-col gap-3">
1794+
<p class="text-xs text-fg-muted">
1795+
{{ $t('package.trends.known_anomalies_description') }}
1796+
</p>
1797+
<div v-if="hasAnomalies">
1798+
<p class="text-xs text-fg-subtle font-medium">
1799+
{{ $t('package.trends.known_anomalies_ranges') }}
1800+
</p>
1801+
<ul class="text-xs text-fg-subtle list-disc list-inside">
1802+
<li v-for="a in packageAnomalies" :key="`${a.packageName}-${a.start}`">
1803+
{{
1804+
isMultiPackageMode
1805+
? $t('package.trends.known_anomalies_range_named', {
1806+
packageName: a.packageName,
1807+
start: formatAnomalyDate(a.start),
1808+
end: formatAnomalyDate(a.end),
1809+
})
1810+
: $t('package.trends.known_anomalies_range', {
1811+
start: formatAnomalyDate(a.start),
1812+
end: formatAnomalyDate(a.end),
1813+
})
1814+
}}
1815+
</li>
1816+
</ul>
1817+
</div>
1818+
<p v-else class="text-xs text-fg-muted">
1819+
{{ $t('package.trends.known_anomalies_none', effectivePackageNames.length) }}
1820+
</p>
1821+
<div class="flex justify-end">
1822+
<LinkBase
1823+
to="https://github.com/npmx-dev/npmx.dev/edit/main/app/utils/download-anomalies.data.ts"
1824+
class="text-xs text-accent"
1825+
>
1826+
{{ $t('package.trends.known_anomalies_contribute') }}
1827+
</LinkBase>
1828+
</div>
1829+
</div>
1830+
</template>
1831+
</TooltipApp>
1832+
</span>
1833+
<label
1834+
class="flex items-center gap-1.5 text-2xs font-mono text-fg-subtle cursor-pointer"
1835+
:class="{ 'opacity-50 pointer-events-none': !hasAnomalies }"
1836+
>
1837+
<input
1838+
v-model="settings.chartFilter.anomaliesFixed"
1839+
type="checkbox"
1840+
:disabled="!hasAnomalies"
1841+
class="accent-[var(--accent-color,var(--fg-subtle))]"
1842+
/>
1843+
{{ $t('package.trends.apply_correction') }}
1844+
</label>
1845+
</div>
1846+
</div>
1847+
</div>
1848+
16891849
<p v-if="skippedPackagesWithoutGitHub.length > 0" class="text-2xs font-mono text-fg-subtle">
16901850
{{ $t('package.trends.contributors_skip', { count: skippedPackagesWithoutGitHub.length }) }}
16911851
{{ skippedPackagesWithoutGitHub.join(', ') }}

app/components/Package/WeeklyDownloadStats.vue

Lines changed: 17 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 { applyDataCorrection } from '~/utils/chart-data-correction'
56
import { OKLCH_NEUTRAL_FALLBACK, lightenOklch } from '~/utils/colors'
7+
import { applyBlocklistCorrection } 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,22 @@ watch(
177179
() => loadWeeklyDownloads(),
178180
)
179181
182+
const correctedDownloads = computed<WeeklyDataPoint[]>(() => {
183+
let data = weeklyDownloads.value as WeeklyDataPoint[]
184+
if (!data.length) return data
185+
if (settings.value.chartFilter.anomaliesFixed) {
186+
data = applyBlocklistCorrection({
187+
data,
188+
packageName: props.packageName,
189+
granularity: 'weekly',
190+
}) as WeeklyDataPoint[]
191+
}
192+
data = applyDataCorrection(data, settings.value.chartFilter) as WeeklyDataPoint[]
193+
return data
194+
})
195+
180196
const dataset = computed<VueUiSparklineDatasetItem[]>(() =>
181-
weeklyDownloads.value.map(d => ({
197+
correctedDownloads.value.map(d => ({
182198
value: d?.value ?? 0,
183199
period: $t('package.trends.date_range', {
184200
start: d.weekStart ?? '-',

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+
averageWindow: number
43+
smoothingTau: number
44+
anomaliesFixed: boolean
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+
averageWindow: 0,
65+
smoothingTau: 1,
66+
anomaliesFixed: true,
67+
},
5868
}
5969

6070
const STORAGE_KEY = 'npmx-settings'

app/utils/chart-data-correction.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
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.
6+
*
7+
* @param halfWindow - number of points on each side (0 = disabled)
8+
*/
9+
export function movingAverage<T extends { value: number }>(data: T[], halfWindow: number): T[] {
10+
if (halfWindow <= 0 || data.length < 3) return data
11+
12+
const n = data.length
13+
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+
}
22+
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+
}
31+
32+
// Position-based blend: near start → mostly trailing, near end → mostly leading
33+
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)
53+
const n = data.length
54+
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+
}
61+
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+
}
68+
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]!
75+
}
76+
77+
return result
78+
}
79+
80+
export interface ChartFilterSettings {
81+
averageWindow: number
82+
smoothingTau: number
83+
}
84+
85+
/**
86+
* Applies moving average then smoothing in sequence.
87+
*/
88+
export function applyDataCorrection<T extends { value: number }>(
89+
data: T[],
90+
settings: ChartFilterSettings,
91+
): T[] {
92+
let result = data
93+
result = movingAverage(result, settings.averageWindow)
94+
result = smoothing(result, settings.smoothingTau)
95+
return result
96+
}
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+
]

0 commit comments

Comments
 (0)