Skip to content

Commit 88dce54

Browse files
committed
feat: add quadrant chart utility
1 parent 9f780bb commit 88dce54

File tree

2 files changed

+922
-0
lines changed

2 files changed

+922
-0
lines changed
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
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

Comments
 (0)