@@ -6,6 +6,10 @@ import { useCssVariables } from '~/composables/useColors'
66import { OKLCH_NEUTRAL_FALLBACK , transparentizeOklch } from ' ~/utils/colors'
77import { getFrameworkColor , isListedFramework } from ' ~/utils/frameworks'
88import { drawNpmxLogoAndTaglineWatermark } from ' ~/composables/useChartWatermark'
9+ import type { RepoRef } from ' #shared/utils/git-providers'
10+ import { parseRepoUrl } from ' #shared/utils/git-providers'
11+ import type { PackageMetaResponse } from ' #shared/types'
12+ import { encodePackageName } from ' #shared/utils/npm'
913import type {
1014 ChartTimeGranularity ,
1115 DailyDataPoint ,
@@ -35,6 +39,7 @@ const props = withDefaults(
3539 * Used when `weeklyDownloads` is not provided.
3640 */
3741 packageNames? : string []
42+ repoRef? : RepoRef | null
3843 createdIso? : string | null
3944
4045 /** When true, shows facet selector (e.g. Downloads / Likes). */
@@ -332,6 +337,56 @@ const effectivePackageNames = computed<string[]>(() => {
332337 return single ? [single ] : []
333338})
334339
340+ const repoRefsByPackage = shallowRef <Record <string , RepoRef | null >>({})
341+ const repoRefsRequestToken = shallowRef (0 )
342+
343+ async function loadRepoRefsForPackages(packages : string []) {
344+ if (! import .meta .client ) return
345+ if (! packages .length ) {
346+ repoRefsByPackage .value = {}
347+ return
348+ }
349+
350+ const currentToken = ++ repoRefsRequestToken .value
351+
352+ const settled = await Promise .allSettled (
353+ packages .map (async name => {
354+ const encoded = encodePackageName (name )
355+ const meta = await $fetch <PackageMetaResponse >(` /api/registry/package-meta/${encoded } ` )
356+ const repoUrl = meta ?.links ?.repository
357+ const ref = repoUrl ? parseRepoUrl (repoUrl ) : null
358+ return { name , ref }
359+ }),
360+ )
361+
362+ if (currentToken !== repoRefsRequestToken .value ) return
363+
364+ const next: Record <string , RepoRef | null > = {}
365+ for (const [index, entry] of settled .entries ()) {
366+ const name = packages [index ]
367+ if (! name ) continue
368+ if (entry .status === ' fulfilled' ) {
369+ next [name ] = entry .value .ref ?? null
370+ } else {
371+ next [name ] = null
372+ }
373+ }
374+ repoRefsByPackage .value = next
375+ }
376+
377+ watch (
378+ () => effectivePackageNames .value ,
379+ names => {
380+ if (! import .meta .client ) return
381+ if (! isMultiPackageMode .value ) {
382+ repoRefsByPackage .value = {}
383+ return
384+ }
385+ loadRepoRefsForPackages (names )
386+ },
387+ { immediate: true },
388+ )
389+
335390const selectedGranularity = usePermalink <ChartTimeGranularity >(' granularity' , DEFAULT_GRANULARITY , {
336391 permanent: props .permalink ,
337392})
@@ -571,35 +626,108 @@ function applyDateRange<T extends Record<string, unknown>>(base: T): T & DateRan
571626 return next
572627}
573628
574- const { fetchPackageDownloadEvolution, fetchPackageLikesEvolution } = useCharts ()
629+ const {
630+ fetchPackageDownloadEvolution,
631+ fetchPackageLikesEvolution,
632+ fetchRepoContributorsEvolution,
633+ } = useCharts ()
575634
576- type MetricId = ' downloads' | ' likes'
635+ type MetricId = ' downloads' | ' likes' | ' contributors '
577636const DEFAULT_METRIC_ID: MetricId = ' downloads'
578637
638+ type MetricContext = {
639+ packageName: string
640+ repoRef: RepoRef | null
641+ }
642+
579643type MetricDef = {
580644 id: MetricId
581645 label: string
582- fetch: (pkg : string , options : EvolutionOptions ) => Promise <EvolutionData >
646+ fetch: (context : MetricContext , options : EvolutionOptions ) => Promise <EvolutionData >
647+ supportsMulti? : boolean
583648}
584649
585- const METRICS = computed <MetricDef []>(() => [
586- {
587- id: ' downloads' ,
588- label: $t (' package.trends.items.downloads' ),
589- fetch : (pkg , opts ) =>
590- fetchPackageDownloadEvolution (pkg , props .createdIso ?? null , opts ) as Promise <EvolutionData >,
591- },
592- {
593- id: ' likes' ,
594- label: $t (' package.trends.items.likes' ),
595- fetch : (pkg , opts ) => fetchPackageLikesEvolution (pkg , opts ) as Promise <EvolutionData >,
596- },
597- ])
650+ const hasContributorsFacet = computed (() => {
651+ if (isMultiPackageMode .value ) {
652+ return Object .values (repoRefsByPackage .value ).some (ref => ref ?.provider === ' github' )
653+ }
654+ const ref = props .repoRef
655+ return ref ?.provider === ' github' && ref .owner && ref .repo
656+ })
657+
658+ const METRICS = computed <MetricDef []>(() => {
659+ const metrics: MetricDef [] = [
660+ {
661+ id: ' downloads' ,
662+ label: $t (' package.trends.items.downloads' ),
663+ fetch : ({ packageName }, opts ) =>
664+ fetchPackageDownloadEvolution (
665+ packageName ,
666+ props .createdIso ?? null ,
667+ opts ,
668+ ) as Promise <EvolutionData >,
669+ supportsMulti: true ,
670+ },
671+ {
672+ id: ' likes' ,
673+ label: $t (' package.trends.items.likes' ),
674+ fetch : ({ packageName }, opts ) => fetchPackageLikesEvolution (packageName , opts ),
675+ supportsMulti: true ,
676+ },
677+ ]
678+
679+ if (hasContributorsFacet .value ) {
680+ metrics .push ({
681+ id: ' contributors' ,
682+ label: $t (' package.trends.items.contributors' ),
683+ fetch : ({ repoRef }, opts ) => fetchRepoContributorsEvolution (repoRef , opts ),
684+ supportsMulti: false ,
685+ })
686+ }
687+
688+ return metrics
689+ })
598690
599691const selectedMetric = usePermalink <MetricId >(' facet' , DEFAULT_METRIC_ID , {
600692 permanent: props .permalink ,
601693})
602694
695+ const effectivePackageNamesForMetric = computed <string []>(() => {
696+ if (! isMultiPackageMode .value ) return effectivePackageNames .value
697+ if (selectedMetric .value !== ' contributors' ) return effectivePackageNames .value
698+ return effectivePackageNames .value .filter (
699+ name => repoRefsByPackage .value [name ]?.provider === ' github' ,
700+ )
701+ })
702+
703+ const availableGranularities = computed <ChartTimeGranularity []>(() => {
704+ if (selectedMetric .value === ' contributors' ) {
705+ return [' weekly' , ' monthly' , ' yearly' ]
706+ }
707+
708+ return [' daily' , ' weekly' , ' monthly' , ' yearly' ]
709+ })
710+
711+ watch (
712+ () => [selectedMetric .value , availableGranularities .value ] as const ,
713+ () => {
714+ if (! availableGranularities .value .includes (selectedGranularity .value )) {
715+ selectedGranularity .value = ' weekly'
716+ }
717+ },
718+ { immediate: true },
719+ )
720+
721+ watch (
722+ () => METRICS .value ,
723+ metrics => {
724+ if (! metrics .some (m => m .id === selectedMetric .value )) {
725+ selectedMetric .value = DEFAULT_METRIC_ID
726+ }
727+ },
728+ { immediate: true },
729+ )
730+
603731// Per-metric state keyed by metric id
604732const metricStates = reactive <
605733 Record <
@@ -624,10 +752,18 @@ const metricStates = reactive<
624752 evolutionsByPackage: {},
625753 requestToken: 0 ,
626754 },
755+ contributors: {
756+ pending: false ,
757+ evolution: [],
758+ evolutionsByPackage: {},
759+ requestToken: 0 ,
760+ },
627761})
628762
629763const activeMetricState = computed (() => metricStates [selectedMetric .value ])
630- const activeMetricDef = computed (() => METRICS .value .find (m => m .id === selectedMetric .value )! )
764+ const activeMetricDef = computed (
765+ () => METRICS .value .find (m => m .id === selectedMetric .value ) ?? METRICS .value [0 ],
766+ )
631767const pending = computed (() => activeMetricState .value .pending )
632768
633769const isMounted = shallowRef (false )
@@ -695,21 +831,33 @@ watch(
695831async function loadMetric(metricId : MetricId ) {
696832 if (! import .meta .client ) return
697833
698- const packageNames = effectivePackageNames .value
699- if (! packageNames .length ) return
700-
701834 const state = metricStates [metricId ]
702835 const metric = METRICS .value .find (m => m .id === metricId )!
703836 const currentToken = ++ state .requestToken
704837 state .pending = true
705838
706- const fetchFn = (pkg : string ) => metric .fetch (pkg , options .value )
839+ const fetchFn = (context : MetricContext ) => metric .fetch (context , options .value )
707840
708841 try {
842+ const packageNames = effectivePackageNamesForMetric .value
843+ if (! packageNames .length ) {
844+ if (isMultiPackageMode .value ) state .evolutionsByPackage = {}
845+ else state .evolution = []
846+ displayedGranularity .value = selectedGranularity .value
847+ return
848+ }
849+
709850 if (isMultiPackageMode .value ) {
851+ if (metric .supportsMulti === false ) {
852+ state .evolutionsByPackage = {}
853+ displayedGranularity .value = selectedGranularity .value
854+ return
855+ }
856+
710857 const settled = await Promise .allSettled (
711858 packageNames .map (async pkg => {
712- const result = await fetchFn (pkg )
859+ const repoRef = metricId === ' contributors' ? repoRefsByPackage .value [pkg ] : null
860+ const result = await fetchFn ({ packageName: pkg , repoRef })
713861 return { pkg , result: (result ?? []) as EvolutionData }
714862 }),
715863 )
@@ -750,7 +898,7 @@ async function loadMetric(metricId: MetricId) {
750898 }
751899 }
752900
753- const result = await fetchFn (pkg )
901+ const result = await fetchFn ({ packageName: pkg , repoRef: props . repoRef ?? null } )
754902 if (currentToken !== state .requestToken ) return
755903
756904 state .evolution = (result ?? []) as EvolutionData
@@ -778,9 +926,13 @@ const debouncedLoadNow = useDebounceFn(() => {
778926const fetchTriggerKey = computed (() => {
779927 const names = effectivePackageNames .value .join (' ,' )
780928 const o = options .value
929+ const repoKey = props .repoRef
930+ ? ` ${props .repoRef .provider }:${props .repoRef .owner }/${props .repoRef .repo } `
931+ : ' '
781932 return [
782933 isMultiPackageMode .value ? ' M' : ' S' ,
783934 names ,
935+ repoKey ,
784936 String (props .createdIso ?? ' ' ),
785937 String (o .granularity ?? ' ' ),
786938 String (' weeks' in o ? (o .weeks ?? ' ' ) : ' ' ),
@@ -800,6 +952,18 @@ watch(
800952 { flush: ' post' },
801953)
802954
955+ watch (
956+ () => repoRefsByPackage .value ,
957+ () => {
958+ if (! import .meta .client ) return
959+ if (! isMounted .value ) return
960+ if (! isMultiPackageMode .value ) return
961+ if (selectedMetric .value !== ' contributors' ) return
962+ debouncedLoadNow ()
963+ },
964+ { deep: true },
965+ )
966+
803967const effectiveDataSingle = computed <EvolutionData >(() => {
804968 const state = activeMetricState .value
805969 if (
@@ -837,7 +1001,7 @@ const chartData = computed<{
8371001 }
8381002
8391003 const state = activeMetricState .value
840- const names = effectivePackageNames .value
1004+ const names = effectivePackageNamesForMetric .value
8411005 const granularity = displayedGranularity .value
8421006
8431007 const timestampSet = new Set <number >()
@@ -936,6 +1100,13 @@ function getGranularityLabel(granularity: ChartTimeGranularity) {
9361100 return granularityLabels .value [granularity ]
9371101}
9381102
1103+ const granularityItems = computed (() =>
1104+ availableGranularities .value .map (granularity => ({
1105+ label: granularityLabels .value [granularity ],
1106+ value: granularity ,
1107+ })),
1108+ )
1109+
9391110function clampRatio(value : number ): number {
9401111 if (value < 0 ) return 0
9411112 if (value > 1 ) return 1
@@ -1114,20 +1285,20 @@ function drawEstimationLine(svg: Record<string, any>) {
11141285
11151286 lines .push (`
11161287 <line
1117- x1="${previousPoint .x }"
1118- y1="${previousPoint .y }"
1119- x2="${lastPoint .x }"
1120- y2="${lastPoint .y }"
1121- stroke="${colors .value .bg }"
1288+ x1="${previousPoint .x }"
1289+ y1="${previousPoint .y }"
1290+ x2="${lastPoint .x }"
1291+ y2="${lastPoint .y }"
1292+ stroke="${colors .value .bg }"
11221293 stroke-width="3"
11231294 opacity="1"
11241295 />
1125- <line
1126- x1="${previousPoint .x }"
1127- y1="${previousPoint .y }"
1128- x2="${lastPoint .x }"
1129- y2="${lastPoint .y }"
1130- stroke="${stroke }"
1296+ <line
1297+ x1="${previousPoint .x }"
1298+ y1="${previousPoint .y }"
1299+ x2="${lastPoint .x }"
1300+ y2="${lastPoint .y }"
1301+ stroke="${stroke }"
11311302 stroke-width="3"
11321303 stroke-dasharray="4 8"
11331304 stroke-linecap="round"
@@ -1240,7 +1411,7 @@ function drawSvgPrintLegend(svg: Record<string, any>) {
12401411 ! isZoomed .value
12411412 ) {
12421413 seriesNames .push (`
1243- <line
1414+ <line
12441415 x1="${svg .drawingArea .left + 12 }"
12451416 y1="${svg .drawingArea .top + 24 * data .length }"
12461417 x2="${svg .drawingArea .left + 24 }"
@@ -1463,12 +1634,7 @@ watch(selectedMetric, value => {
14631634 id =" granularity"
14641635 v-model =" selectedGranularity"
14651636 :disabled =" activeMetricState.pending"
1466- :items =" [
1467- { label: $t('package.trends.granularity_daily'), value: 'daily' },
1468- { label: $t('package.trends.granularity_weekly'), value: 'weekly' },
1469- { label: $t('package.trends.granularity_monthly'), value: 'monthly' },
1470- { label: $t('package.trends.granularity_yearly'), value: 'yearly' },
1471- ]"
1637+ :items =" granularityItems"
14721638 />
14731639
14741640 <div class =" grid grid-cols-2 gap-2 flex-1" >
0 commit comments