1+ export interface PackageQuadrantInput {
2+ id : string
3+ license : string
4+ name : string
5+ downloads ?: number | null
6+ totalLikes ?: number | null
7+ packageSize ?: number | null
8+ installSize ?: number | null
9+ dependencies ?: number | null
10+ totalDependencies ?: number | null
11+ vulnerabilities ?: number | null
12+ deprecated ?: boolean | null
13+ types ?: boolean | null
14+ lastUpdated ?: string | Date | null
15+ }
16+
17+ export interface PackageQuadrantPoint {
18+ id : string
19+ license : string
20+ name : string
21+ x : number
22+ y : number
23+ adoptionScore : number
24+ efficiencyScore : number
25+ quadrant : 'TOP_RIGHT' | 'TOP_LEFT' | 'BOTTOM_RIGHT' | 'BOTTOM_LEFT'
26+ metrics : {
27+ downloads : number
28+ totalLikes : number
29+ packageSize : number
30+ installSize : number
31+ dependencies : number
32+ totalDependencies : number
33+ vulnerabilities : number
34+ deprecated : boolean
35+ types : boolean
36+ freshnessScore : number
37+ freshnessPercent : number
38+ }
39+ }
40+
41+ interface QuadrantMetricRanges {
42+ minimumDownloads : number
43+ maximumDownloads : number
44+ minimumTotalLikes : number
45+ maximumTotalLikes : number
46+ minimumPackageSize : number
47+ maximumPackageSize : number
48+ minimumInstallSize : number
49+ maximumInstallSize : number
50+ minimumDependencies : number
51+ maximumDependencies : number
52+ minimumTotalDependencies : number
53+ maximumTotalDependencies : number
54+ minimumVulnerabilities : number
55+ maximumVulnerabilities : number
56+ minimumLogarithmicDownloads : number
57+ maximumLogarithmicDownloads : number
58+ }
59+
60+ const WEIGHTS = {
61+ // Quadrant X axis
62+ adoption : {
63+ downloads : 0.7 , // dominant signal because they best reflect real-world adoption
64+ freshness : 0.1 , // small correction so stale packages are slightly penalized
65+ likes : 0.01 , // might be pumped up in the future when ./npmx likes are more mainstream
66+ } ,
67+ // Quadrant Y axis
68+ efficiency : {
69+ installSize : 0.3 , // weighted highest because it best reflects consumer footprint
70+ dependencies : 0.2 , // direct deps capture architectural and supply-chain complexity
71+ totalDependencies : 0.15 , // same for total deps
72+ packageSize : 0.1 , // publication weight, less important than installed footprint
73+ vulnerabilities : 0.2 , // penalize security burden
74+ types : 0.1 , // TS support
75+ deprecation : 0.05
76+ }
77+ }
78+ const VULNERABILITY_PENALTY_MULTIPLIER = 2
79+
80+ function clampInRange ( value : number , min = - 1 , max = 1 ) : number {
81+ if ( value < min ) return min
82+ if ( value > max ) return max
83+ return value
84+ }
85+
86+ function normalizeNumber ( value : number , min : number , max : number ) : number {
87+ if ( max === min ) return 0
88+ const normalisedValue = ( value - min ) / ( max - min )
89+ return clampInRange ( normalisedValue * 2 - 1 )
90+ }
91+
92+ function normalizeInverseNumber ( value : number , min : number , max : number ) : number {
93+ return - normalizeNumber ( value , min , max )
94+ }
95+
96+ function normalizeBoolean ( value : boolean ) : number {
97+ return value ? 1 : - 1
98+ }
99+
100+ function toSafeNumber ( value : number | null | undefined , fallback = 0 ) : number {
101+ return typeof value === 'number' && Number . isFinite ( value ) ? value : fallback
102+ }
103+
104+ function getnormalisedFreshness (
105+ value : string | Date | null | undefined ,
106+ maximumAgeInDays = 365 ,
107+ ) : number | null {
108+ if ( ! value ) return null
109+
110+ const date = value instanceof Date ? value : new Date ( value )
111+ if ( Number . isNaN ( date . getTime ( ) ) ) return null
112+
113+ const now = Date . now ( )
114+ const ageInMilliseconds = now - date . getTime ( )
115+ const ageInDays = ageInMilliseconds / ( 1000 * 60 * 60 * 24 )
116+
117+ return 1 - ageInDays / maximumAgeInDays
118+ }
119+
120+ function getFreshnessScore (
121+ value : string | Date | null | undefined ,
122+ maximumAgeInDays = 365 ,
123+ ) : number {
124+ const normalisedAge = getnormalisedFreshness ( value , maximumAgeInDays )
125+ if ( normalisedAge === null ) return - 1
126+ return clampInRange ( normalisedAge * 2 - 1 )
127+ }
128+
129+ function getFreshnessPercentage (
130+ value : string | Date | null | undefined ,
131+ maximumAgeInDays = 365 ,
132+ ) : number {
133+ const normalisedAge = getnormalisedFreshness ( value , maximumAgeInDays )
134+ if ( normalisedAge === null ) return 0
135+ return Math . max ( 0 , Math . min ( 1 , normalisedAge ) ) * 100
136+ }
137+
138+ function getVulnerabilityPenalty (
139+ value : number ,
140+ minimum : number ,
141+ maximum : number ,
142+ ) : number {
143+ const normalised = normalizeInverseNumber ( value , minimum , maximum )
144+ return normalised < 0 ? normalised * VULNERABILITY_PENALTY_MULTIPLIER : normalised
145+ }
146+
147+ function resolveQuadrant ( x : number , y : number ) : PackageQuadrantPoint [ 'quadrant' ] {
148+ if ( x >= 0 && y >= 0 ) return 'TOP_RIGHT'
149+ if ( x < 0 && y >= 0 ) return 'TOP_LEFT'
150+ if ( x >= 0 && y < 0 ) return 'BOTTOM_RIGHT'
151+ return 'BOTTOM_LEFT'
152+ }
153+
154+ function getQuadrantMetricRanges ( packages : PackageQuadrantInput [ ] ) : QuadrantMetricRanges {
155+ const downloadsValues = packages . map ( packageItem => toSafeNumber ( packageItem . downloads ) )
156+ const totalLikesValues = packages . map ( packageItem => toSafeNumber ( packageItem . totalLikes ) )
157+ const packageSizeValues = packages . map ( packageItem => toSafeNumber ( packageItem . packageSize ) )
158+ const installSizeValues = packages . map ( packageItem => toSafeNumber ( packageItem . installSize ) )
159+ const dependenciesValues = packages . map ( packageItem => toSafeNumber ( packageItem . dependencies ) )
160+ const totalDependenciesValues = packages . map ( packageItem =>
161+ toSafeNumber ( packageItem . totalDependencies ) ,
162+ )
163+ const vulnerabilitiesValues = packages . map ( packageItem =>
164+ toSafeNumber ( packageItem . vulnerabilities ) ,
165+ )
166+ const logarithmicDownloadsValues = downloadsValues . map ( value => Math . log ( value + 1 ) )
167+
168+ return {
169+ minimumDownloads : Math . min ( ...downloadsValues ) ,
170+ maximumDownloads : Math . max ( ...downloadsValues ) ,
171+ minimumTotalLikes : Math . min ( ...totalLikesValues ) ,
172+ maximumTotalLikes : Math . max ( ...totalLikesValues ) ,
173+ minimumPackageSize : Math . min ( ...packageSizeValues ) ,
174+ maximumPackageSize : Math . max ( ...packageSizeValues ) ,
175+ minimumInstallSize : Math . min ( ...installSizeValues ) ,
176+ maximumInstallSize : Math . max ( ...installSizeValues ) ,
177+ minimumDependencies : Math . min ( ...dependenciesValues ) ,
178+ maximumDependencies : Math . max ( ...dependenciesValues ) ,
179+ minimumTotalDependencies : Math . min ( ...totalDependenciesValues ) ,
180+ maximumTotalDependencies : Math . max ( ...totalDependenciesValues ) ,
181+ minimumVulnerabilities : Math . min ( ...vulnerabilitiesValues ) ,
182+ maximumVulnerabilities : Math . max ( ...vulnerabilitiesValues ) ,
183+ minimumLogarithmicDownloads : Math . min ( ...logarithmicDownloadsValues ) ,
184+ maximumLogarithmicDownloads : Math . max ( ...logarithmicDownloadsValues ) ,
185+ }
186+ }
187+
188+ function createQuadrantPoint (
189+ packageItem : PackageQuadrantInput ,
190+ metricRanges : QuadrantMetricRanges ,
191+ ) : PackageQuadrantPoint {
192+ const downloads = toSafeNumber ( packageItem . downloads )
193+ const totalLikes = toSafeNumber ( packageItem . totalLikes )
194+ const packageSize = toSafeNumber ( packageItem . packageSize )
195+ const installSize = toSafeNumber ( packageItem . installSize )
196+ const dependencies = toSafeNumber ( packageItem . dependencies )
197+ const totalDependencies = toSafeNumber ( packageItem . totalDependencies )
198+ const vulnerabilities = toSafeNumber ( packageItem . vulnerabilities )
199+ const deprecated = packageItem . deprecated ?? false
200+ const types = packageItem . types ?? false
201+ const freshnessScore = getFreshnessScore ( packageItem . lastUpdated ) // for weighing
202+ const freshnessPercent = getFreshnessPercentage ( packageItem . lastUpdated ) // for display
203+
204+ // Since downloads can span multiple orders of magnitude, log is used to normalise them to produce comparable scores instead of collapsing most values into noise
205+ const normalisedDownloads = normalizeNumber (
206+ Math . log ( downloads + 1 ) ,
207+ metricRanges . minimumLogarithmicDownloads ,
208+ metricRanges . maximumLogarithmicDownloads ,
209+ )
210+
211+ const normalisedTotalLikes = normalizeNumber (
212+ totalLikes ,
213+ metricRanges . minimumTotalLikes ,
214+ metricRanges . maximumTotalLikes ,
215+ )
216+
217+ const normalisedInstallSize = normalizeInverseNumber (
218+ installSize ,
219+ metricRanges . minimumInstallSize ,
220+ metricRanges . maximumInstallSize ,
221+ )
222+
223+ const normalisedDependencies = normalizeInverseNumber (
224+ dependencies ,
225+ metricRanges . minimumDependencies ,
226+ metricRanges . maximumDependencies ,
227+ )
228+
229+ const normalisedTotalDependencies = normalizeInverseNumber (
230+ totalDependencies ,
231+ metricRanges . minimumTotalDependencies ,
232+ metricRanges . maximumTotalDependencies ,
233+ )
234+
235+ const normalisedPackageSize = normalizeInverseNumber (
236+ packageSize ,
237+ metricRanges . minimumPackageSize ,
238+ metricRanges . maximumPackageSize ,
239+ )
240+
241+ const normalisedVulnerabilities = getVulnerabilityPenalty (
242+ vulnerabilities ,
243+ metricRanges . minimumVulnerabilities ,
244+ metricRanges . maximumVulnerabilities ,
245+ )
246+
247+ const deprecationScore = normalizeBoolean ( ! deprecated )
248+ const typesScore = normalizeBoolean ( types )
249+
250+ const adoptionScore = clampInRange (
251+ normalisedDownloads * WEIGHTS . adoption . downloads +
252+ freshnessScore * WEIGHTS . adoption . freshness +
253+ normalisedTotalLikes * WEIGHTS . adoption . likes ,
254+ )
255+
256+ const rawEfficiencyScore =
257+ normalisedInstallSize * WEIGHTS . efficiency . installSize +
258+ normalisedDependencies * WEIGHTS . efficiency . dependencies +
259+ normalisedTotalDependencies * WEIGHTS . efficiency . totalDependencies +
260+ normalisedPackageSize * WEIGHTS . efficiency . packageSize +
261+ normalisedVulnerabilities * WEIGHTS . efficiency . vulnerabilities +
262+ typesScore * WEIGHTS . efficiency . types +
263+ deprecationScore * WEIGHTS . efficiency . deprecation
264+
265+ // Deprecation considered harmful
266+ const efficiencyScore = deprecated
267+ ? - 1
268+ : clampInRange ( rawEfficiencyScore )
269+
270+ const quadrant = resolveQuadrant ( adoptionScore , efficiencyScore )
271+
272+ return {
273+ adoptionScore,
274+ efficiencyScore,
275+ id : packageItem . id ,
276+ license : packageItem . license ,
277+ name : packageItem . name ,
278+ metrics : {
279+ dependencies,
280+ deprecated,
281+ downloads,
282+ freshnessPercent,
283+ freshnessScore,
284+ installSize,
285+ packageSize,
286+ totalDependencies,
287+ totalLikes,
288+ types,
289+ vulnerabilities,
290+ } ,
291+ quadrant,
292+ x : adoptionScore ,
293+ y : efficiencyScore ,
294+ }
295+ }
296+
297+ export function createQuadrantDataset ( packages : PackageQuadrantInput [ ] ) : PackageQuadrantPoint [ ] {
298+ if ( ! packages . length ) return [ ]
299+ const metricRanges = getQuadrantMetricRanges ( packages )
300+ return packages . map ( packageItem => createQuadrantPoint ( packageItem , metricRanges ) )
301+ }
0 commit comments