diff --git a/app/components/Compare/FacetBarChart.vue b/app/components/Compare/FacetBarChart.vue index 67bed343e6..0162f52930 100644 --- a/app/components/Compare/FacetBarChart.vue +++ b/app/components/Compare/FacetBarChart.vue @@ -196,7 +196,7 @@ const config = computed(() => { }, nameLabels: { fontSize: isMobile.value ? 12 : 18, - color: colors.value.fgSubtle, + color: colors.value.fg, }, underlayerColor: colors.value.bg, }, diff --git a/app/components/Compare/FacetQuadrantChart.vue b/app/components/Compare/FacetQuadrantChart.vue new file mode 100644 index 0000000000..232e539414 --- /dev/null +++ b/app/components/Compare/FacetQuadrantChart.vue @@ -0,0 +1,501 @@ + + + + + diff --git a/app/composables/useChartWatermark.ts b/app/composables/useChartWatermark.ts index 48e6adf96c..c927bae769 100644 --- a/app/composables/useChartWatermark.ts +++ b/app/composables/useChartWatermark.ts @@ -76,15 +76,23 @@ export function drawNpmxLogoAndTaglineWatermark({ colors, translateFn, positioning = 'bottom', + sizeRatioLogo = 1, + sizeRatioTagline = 1, + offsetYTagline = -6, + offsetYLogo = 0, }: { svg: Record colors: WatermarkColors translateFn: (key: string) => string positioning?: 'bottom' | 'belowDrawingArea' + sizeRatioLogo?: number + sizeRatioTagline?: number + offsetYTagline?: number + offsetYLogo?: number }) { if (!svg?.drawingArea) return '' - const npmxLogoWidthToHeight = 2.64 - const npmxLogoWidth = 100 + const npmxLogoWidthToHeight = 2.64 * sizeRatioLogo + const npmxLogoWidth = 100 * sizeRatioLogo const npmxLogoHeight = npmxLogoWidth / npmxLogoWidthToHeight // Position watermark based on the positioning strategy @@ -94,18 +102,20 @@ export function drawNpmxLogoAndTaglineWatermark({ : svg.height - npmxLogoHeight const taglineY = - positioning === 'belowDrawingArea' ? watermarkY - 6 : svg.height - npmxLogoHeight - 6 + positioning === 'belowDrawingArea' + ? watermarkY + offsetYTagline + : svg.height - npmxLogoHeight + offsetYTagline // Center the watermark horizontally relative to the full SVG width const watermarkX = svg.width / 2 - npmxLogoWidth / 2 return ` - ${generateWatermarkLogo({ x: watermarkX, y: watermarkY, width: npmxLogoWidth, height: npmxLogoHeight, fill: colors.fg })} + ${generateWatermarkLogo({ x: watermarkX, y: watermarkY + offsetYLogo, width: npmxLogoWidth, height: npmxLogoHeight, fill: colors.fg })} ${translateFn('tagline')} diff --git a/app/pages/compare.vue b/app/pages/compare.vue index 5f9bf00c0f..4f40a5e803 100644 --- a/app/pages/compare.vue +++ b/app/pages/compare.vue @@ -2,6 +2,7 @@ import { NO_DEPENDENCY_ID } from '~/composables/usePackageComparison' import { useRouteQuery } from '@vueuse/router' import FacetBarChart from '~/components/Compare/FacetBarChart.vue' +import FacetQuadrantChart from '~/components/Compare/FacetQuadrantChart.vue' definePageMeta({ name: 'compare', @@ -330,7 +331,7 @@ useSeoMeta({ - +
{{ $t('compare.packages.no_chartable_data') }}

+
+ +
diff --git a/app/utils/charts.ts b/app/utils/charts.ts index 6997b8c025..6d9f5de5c2 100644 --- a/app/utils/charts.ts +++ b/app/utils/charts.ts @@ -2,6 +2,8 @@ import type { AltCopyArgs, VueUiHorizontalBarConfig, VueUiHorizontalBarDatapoint, + VueUiQuadrantConfig, + VueUiQuadrantDatapoint, VueUiXyConfig, VueUiXyDatasetBarItem, VueUiXyDatasetLineItem, @@ -636,6 +638,81 @@ export async function copyAltTextForCompareFacetBarChart({ await config.copy(altText) } +type CompareQuadrantChartConfig = VueUiQuadrantConfig & { + copy: (text: string) => Promise + $t: TrendTranslateFunction +} + +// Used for FacetQuadrantChart.vue +export function createAltTextForCompareQuadrantChart({ + dataset, + config, +}: AltCopyArgs) { + if (!dataset) return '' + + const packages = { + topRight: dataset.filter(d => d.quadrant === 'TOP_RIGHT'), + topLeft: dataset.filter(d => d.quadrant === 'TOP_LEFT'), + bottomRight: dataset.filter(d => d.quadrant === 'BOTTOM_RIGHT'), + bottomLeft: dataset.filter(d => d.quadrant === 'BOTTOM_LEFT'), + } + + const descriptions = { + topRight: '', + topLeft: '', + bottomRight: '', + bottomLeft: '', + } + + if (packages.topRight.length) { + descriptions.topRight = config.$t('compare.quadrant_chart.copy_alt.side_analysis_top_right', { + packages: packages.topRight.map(p => p.fullname).join(', '), + }) + } + + if (packages.topLeft.length) { + descriptions.topLeft = config.$t('compare.quadrant_chart.copy_alt.side_analysis_top_left', { + packages: packages.topLeft.map(p => p.fullname).join(', '), + }) + } + + if (packages.bottomRight.length) { + descriptions.bottomRight = config.$t( + 'compare.quadrant_chart.copy_alt.side_analysis_bottom_right', + { + packages: packages.bottomRight.map(p => p.fullname).join(', '), + }, + ) + } + + if (packages.bottomLeft.length) { + descriptions.bottomLeft = config.$t( + 'compare.quadrant_chart.copy_alt.side_analysis_bottom_left', + { + packages: packages.bottomLeft.map(p => p.fullname).join(', '), + }, + ) + } + + const analysis = Object.values(descriptions).filter(Boolean).join('. ') + + const altText = config.$t('compare.quadrant_chart.copy_alt.description', { + packages: dataset.map(p => p.fullname).join(', '), + analysis, + watermark: config.$t('package.trends.copy_alt.watermark'), + }) + + return altText +} + +export async function copyAltTextForCompareQuadrantChart({ + dataset, + config, +}: AltCopyArgs) { + const altText = createAltTextForCompareQuadrantChart({ dataset, config }) + await config.copy(altText) +} + // Used in chart context menu callbacks // @todo replace with downloadFileLink export function loadFile(link: string, filename: string) { diff --git a/app/utils/compare-quadrant-chart.ts b/app/utils/compare-quadrant-chart.ts new file mode 100644 index 0000000000..97d9879812 --- /dev/null +++ b/app/utils/compare-quadrant-chart.ts @@ -0,0 +1,221 @@ +export interface PackageQuadrantInput { + id: string + license: string + name: string + downloads?: number | null + totalLikes?: number | null + packageSize?: number | null + installSize?: number | null + dependencies?: number | null + totalDependencies?: number | null + vulnerabilities?: number | null + deprecated?: boolean | null + types?: boolean | null + lastUpdated?: string | Date | null +} + +export interface PackageQuadrantPoint { + id: string + license: string + name: string + x: number + y: number + adoptionScore: number + efficiencyScore: number + quadrant: 'TOP_RIGHT' | 'TOP_LEFT' | 'BOTTOM_RIGHT' | 'BOTTOM_LEFT' + metrics: { + downloads: number + totalLikes: number + packageSize: number + installSize: number + dependencies: number + totalDependencies: number + vulnerabilities: number + deprecated: boolean + types: boolean + freshnessScore: number + freshnessPercent: number + } +} + +const WEIGHTS = { + adoption: { + downloads: 0.75, // dominant signal because they best reflect real-world adoption (in the data we have through facets currently) + freshness: 0.15, // small correction so stale packages are slightly + likes: 0.1, // might be pumped up in the future when ./npmx likes are more mainstream + }, + efficiency: { + installSize: 0.3, // weighted highest because it best reflects consumer footprint + + // dependency weights are already measured in install size in some way, but still useful knobs to find the sweet spot + dependencies: 0.05, // direct deps capture architectural and supply-chain complexity + totalDependencies: 0.2, // same for total deps + + packageSize: 0.1, + vulnerabilities: 0.2, // penalize security burden + types: 0.15, // TS support + // Note: the 'deprecated' metric is not weighed because it just forces a -1 evaluation + }, +} + +/* Fixed logarithmic ceilings to normalize metrics onto a stable [-1, 1] scale. + * This avoids dataset-relative min/max normalization, which would shift scores depending + * on which packages are being compared. Ceilings act as reference points for what is + * considered 'high' for each metric, ensuring consistent positioning across different + * datasets while preserving meaningful differences via log scaling. + */ +const LOG_CEILINGS = { + downloads: 100_000_000, + likes: 1000, // might be pumped up in the future when ./npmx likes are more mainstream + installSize: 25_000_000, + dependencies: 100, + totalDependencies: 1_000, + packageSize: 15_000_000, +} + +const VULNERABILITY_PENALTY_MULTIPLIER = 2 + +function clampInRange(value: number, min = -1, max = 1): number { + if (value < min) return min + if (value > max) return max + return value +} + +function normalizeBoolean(value: boolean): number { + return value ? 1 : -1 +} + +function toSafeNumber(value: number | null | undefined, fallback = 0): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +function getNormalisedFreshness( + value: string | Date | null | undefined, + maximumAgeInDays = 365, +): number | null { + if (!value) return null + + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) return null + + const now = Date.now() + const ageInMilliseconds = now - date.getTime() + const ageInDays = ageInMilliseconds / (1000 * 60 * 60 * 24) + + return 1 - ageInDays / maximumAgeInDays +} + +function getFreshnessScore( + value: string | Date | null | undefined, + maximumAgeInDays = 365, +): number { + const normalisedAge = getNormalisedFreshness(value, maximumAgeInDays) + if (normalisedAge === null) return -1 + return clampInRange(normalisedAge * 2 - 1) +} + +function getFreshnessPercentage( + value: string | Date | null | undefined, + maximumAgeInDays = 365, +): number { + const normalisedAge = getNormalisedFreshness(value, maximumAgeInDays) + if (normalisedAge === null) return 0 + return Math.max(0, Math.min(1, normalisedAge)) * 100 +} + +function normalizeLogHigherBetter(value: number, upperBound: number): number { + const safeValue = Math.max(0, value) + const safeUpperBound = Math.max(1, upperBound) + const normalised = Math.log(safeValue + 1) / Math.log(safeUpperBound + 1) + return clampInRange(normalised * 2 - 1) +} + +function normalizeLogLowerBetter(value: number, upperBound: number): number { + return -normalizeLogHigherBetter(value, upperBound) +} + +function getVulnerabilityPenalty(value: number): number { + if (value <= 0) return 1 + + const penalty = normalizeLogLowerBetter(value, 10) + return penalty < 0 ? penalty * VULNERABILITY_PENALTY_MULTIPLIER : penalty +} + +function resolveQuadrant(x: number, y: number): PackageQuadrantPoint['quadrant'] { + if (x >= 0 && y >= 0) return 'TOP_RIGHT' + if (x < 0 && y >= 0) return 'TOP_LEFT' + if (x >= 0 && y < 0) return 'BOTTOM_RIGHT' + return 'BOTTOM_LEFT' +} + +function createQuadrantPoint(packageItem: PackageQuadrantInput): PackageQuadrantPoint { + const downloads = toSafeNumber(packageItem.downloads) + const totalLikes = toSafeNumber(packageItem.totalLikes) + const packageSize = toSafeNumber(packageItem.packageSize) + const installSize = toSafeNumber(packageItem.installSize) + const dependencies = toSafeNumber(packageItem.dependencies) + const totalDependencies = toSafeNumber(packageItem.totalDependencies) + const vulnerabilities = toSafeNumber(packageItem.vulnerabilities) + const deprecated = packageItem.deprecated ?? false + const types = packageItem.types ?? false + const freshnessScore = getFreshnessScore(packageItem.lastUpdated) // for weighing + const freshnessPercent = getFreshnessPercentage(packageItem.lastUpdated) // for display + + const normalisedDownloads = normalizeLogHigherBetter(downloads, LOG_CEILINGS.downloads) + const normalisedLikes = normalizeLogHigherBetter(totalLikes, LOG_CEILINGS.likes) + const normalisedInstallSize = normalizeLogLowerBetter(installSize, LOG_CEILINGS.installSize) + const normalisedDependencies = normalizeLogLowerBetter(dependencies, LOG_CEILINGS.dependencies) + const normalisedTotalDependencies = normalizeLogLowerBetter( + totalDependencies, + LOG_CEILINGS.totalDependencies, + ) + const normalisedPackageSize = normalizeLogLowerBetter(packageSize, LOG_CEILINGS.packageSize) + + const normalisedVulnerabilities = getVulnerabilityPenalty(vulnerabilities) + const typesScore = normalizeBoolean(types) + + const adoptionScore = clampInRange( + normalisedDownloads * WEIGHTS.adoption.downloads + + freshnessScore * WEIGHTS.adoption.freshness + + normalisedLikes * WEIGHTS.adoption.likes, + ) + + const rawEfficiencyScore = + normalisedInstallSize * WEIGHTS.efficiency.installSize + + normalisedDependencies * WEIGHTS.efficiency.dependencies + + normalisedTotalDependencies * WEIGHTS.efficiency.totalDependencies + + normalisedPackageSize * WEIGHTS.efficiency.packageSize + + normalisedVulnerabilities * WEIGHTS.efficiency.vulnerabilities + + typesScore * WEIGHTS.efficiency.types + + const efficiencyScore = deprecated ? -1 : clampInRange(rawEfficiencyScore) + const quadrant = resolveQuadrant(adoptionScore, efficiencyScore) + + return { + adoptionScore, + efficiencyScore, + id: packageItem.id, + license: packageItem.license, + name: packageItem.name, + metrics: { + dependencies, + deprecated, + downloads, + freshnessPercent, + freshnessScore, + installSize, + packageSize, + totalDependencies, + totalLikes, + types, + vulnerabilities, + }, + quadrant, + x: adoptionScore, + y: efficiencyScore, + } +} + +export function createQuadrantDataset(packages: PackageQuadrantInput[]): PackageQuadrantPoint[] { + return packages.map(packageItem => createQuadrantPoint(packageItem)) +} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index c073827c3f..b108a6d588 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -201,6 +201,8 @@ "warnings": "Warnings:", "go_back_home": "Go back home", "per_week": "/ week", + "yes": "Yes", + "no": "No", "vanity_downloads_hint": "Vanity number: no packages displayed | Vanity number: for the displayed package | Vanity number: Sum of {count} displayed packages", "sort": { "name": "name", @@ -1122,6 +1124,31 @@ "packages_selected": "{count}/{max} packages selected.", "add_hint": "Add at least 2 packages to compare." }, + "quadrant_chart": { + "label_x_axis": "Adoption", + "label_y_axis": "Efficiency", + "label_top_right": "high impact", + "label_bottom_right": "popular but heavy", + "label_bottom_left": "low impact", + "label_top_left": "promising", + "title": "Adoption vs Package efficiency", + "filename": "package-adoption-vs-efficiency-matrix", + "label_freshness_score": "Freshness score", + "copy_alt": { + "description": "Quadrant chart mapping adoption versus efficiency for the {packages} packages. {analysis}. {watermark}.", + "side_analysis_top_right": "The following packages are positioned on the top-right quadrant (high-impact): {packages}", + "side_analysis_top_left": "The following packages are positioned on the top-left quadrant (promising): {packages}", + "side_analysis_bottom_right": "The following packages are positioned on the bottom-right quadrant (popular but heavy): {packages}", + "side_analysis_bottom_left": "The following packages are positioned on the bottom-left quadrant (low-impact): {packages}" + }, + "explanation": { + "tooltip_help": "Show scoring explanation", + "introduction": "The score is calculated by combining multiple signals into two axes:", + "adoption": "Adoption: reflects usage and activity (downloads, freshness, likes)", + "efficiency": "Package efficiency: reflects footprint and quality (install size, dependencies, vulnerabilities, type support)", + "impact_details": "Each metric contributes with a different weight. Strong signals like downloads and install size have the largest impact, while others refine the result. Some signals (such as vulnerabilities or deprecation) apply penalties." + } + }, "no_dependency": { "label": "(No dependency)", "typeahead_title": "What Would James Do?", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 8f34c94f0d..3e6642176d 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -1118,6 +1118,31 @@ "packages_selected": "{count}/{max} paquets sélectionnés.", "add_hint": "Ajoutez au moins 2 paquets à comparer." }, + "quadrant_chart": { + "label_x_axis": "Adoption", + "label_y_axis": "Efficacité du paquet", + "label_top_right": "fort impact", + "label_bottom_right": "populaire mais lourd", + "label_bottom_left": "faible impact", + "label_top_left": "prometteur", + "title": "Adoption vs efficacité du paquet", + "filename": "matrice-adoption-vs-efficacite-paquet", + "label_freshness_score": "Score de fraîcheur", + "copy_alt": { + "description": "Graphique en quadrants représentant l’adoption et l’efficacité pour les paquets {packages}. {analysis}. {watermark}.", + "side_analysis_top_right": "Les paquets suivants sont positionnés dans le quadrant en haut à droite (fort impact) : {packages}", + "side_analysis_top_left": "Les paquets suivants sont positionnés dans le quadrant en haut à gauche (prometteur) : {packages}", + "side_analysis_bottom_right": "Les paquets suivants sont positionnés dans le quadrant en bas à droite (populaires mais lourds) : {packages}", + "side_analysis_bottom_left": "Les paquets suivants sont positionnés dans le quadrant en bas à gauche (faible impact) : {packages}" + }, + "explanation": { + "tooltip_help": "Afficher l'explication du score", + "introduction": "Le score est calculé en combinant plusieurs signaux selon deux axes :", + "adoption": "Adoption : reflète l’utilisation et l’activité (téléchargements, fraîcheur, likes)", + "efficiency": "Efficacité du paquet : reflète l’empreinte et la qualité (taille installée, dépendances, vulnérabilités, support TypeScript)", + "impact_details": "Chaque métrique contribue avec un poids différent. Les signaux forts comme les téléchargements et la taille installée ont le plus d’impact, tandis que les autres affinent le résultat. Certains signaux (comme les vulnérabilités ou la dépréciation) appliquent des pénalités." + } + }, "no_dependency": { "label": "(Sans dépendance)", "typeahead_title": "Et sans dépendance ?", diff --git a/i18n/schema.json b/i18n/schema.json index a621a2e288..9343f7d331 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -607,6 +607,12 @@ "per_week": { "type": "string" }, + "yes": { + "type": "string" + }, + "no": { + "type": "string" + }, "vanity_downloads_hint": { "type": "string" }, @@ -3370,6 +3376,81 @@ }, "additionalProperties": false }, + "quadrant_chart": { + "type": "object", + "properties": { + "label_x_axis": { + "type": "string" + }, + "label_y_axis": { + "type": "string" + }, + "label_top_right": { + "type": "string" + }, + "label_bottom_right": { + "type": "string" + }, + "label_bottom_left": { + "type": "string" + }, + "label_top_left": { + "type": "string" + }, + "title": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "label_freshness_score": { + "type": "string" + }, + "copy_alt": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "side_analysis_top_right": { + "type": "string" + }, + "side_analysis_top_left": { + "type": "string" + }, + "side_analysis_bottom_right": { + "type": "string" + }, + "side_analysis_bottom_left": { + "type": "string" + } + }, + "additionalProperties": false + }, + "explanation": { + "type": "object", + "properties": { + "tooltip_help": { + "type": "string" + }, + "introduction": { + "type": "string" + }, + "adoption": { + "type": "string" + }, + "efficiency": { + "type": "string" + }, + "impact_details": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, "no_dependency": { "type": "object", "properties": { diff --git a/nuxt.config.ts b/nuxt.config.ts index 6a317eed0d..d206d0824f 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -385,6 +385,8 @@ export default defineNuxtConfig({ '@vueuse/integrations/useFocusTrap/component', 'vue-data-ui/vue-ui-sparkline', 'vue-data-ui/vue-ui-xy', + 'vue-data-ui/vue-ui-quadrant', + 'vue-data-ui/vue-ui-horizontal-bar', 'virtua/vue', 'semver', 'validate-npm-package-name', diff --git a/package.json b/package.json index f010bdea9d..d17deca46f 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ "vite-plugin-pwa": "1.2.0", "vite-plus": "0.1.12", "vue": "3.5.30", - "vue-data-ui": "3.17.3" + "vue-data-ui": "3.17.10" }, "devDependencies": { "@e18e/eslint-plugin": "0.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2b39e2f38..84069da342 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -235,8 +235,8 @@ importers: specifier: 3.5.30 version: 3.5.30(typescript@6.0.2) vue-data-ui: - specifier: 3.17.3 - version: 3.17.3(vue@3.5.30(typescript@6.0.2)) + specifier: 3.17.10 + version: 3.17.10(vue@3.5.30(typescript@6.0.2)) devDependencies: '@e18e/eslint-plugin': specifier: 0.3.0 @@ -10724,8 +10724,8 @@ packages: vue-component-type-helpers@3.2.6: resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==} - vue-data-ui@3.17.3: - resolution: {integrity: sha512-Adfyb/x2Jy0BdpGgugckK44/XRW43lVChqsJuM3cVdx9x87A9Q4sDfH2HjAxLH2BWIeQcEgkI6ozXf8m/97tHw==} + vue-data-ui@3.17.10: + resolution: {integrity: sha512-Agg9iowiVg8/hc99iUI5+PZqeLrmhnDkX6SWge7f3gpnqruwYDCsVdyJdtgeNSq5INOAx3wbe+gm8PiC11VjTw==} peerDependencies: jspdf: '>=3.0.1' vue: '>=3.3.0' @@ -23605,7 +23605,7 @@ snapshots: vue-component-type-helpers@3.2.6: {} - vue-data-ui@3.17.3(vue@3.5.30(typescript@6.0.2)): + vue-data-ui@3.17.10(vue@3.5.30(typescript@6.0.2)): dependencies: vue: 3.5.30(typescript@6.0.2) diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 46bba7bba1..c044490726 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -250,6 +250,7 @@ import ToggleServer from '~/components/Settings/Toggle.server.vue' import SearchProviderToggleServer from '~/components/SearchProviderToggle.server.vue' import PackageTrendsChart from '~/components/Package/TrendsChart.vue' import FacetBarChart from '~/components/Compare/FacetBarChart.vue' +import FacetQuadrantChart from '~/components/Compare/FacetQuadrantChart.vue' import PackageLikeCard from '~/components/Package/LikeCard.vue' import SizeIncrease from '~/components/Package/SizeIncrease.vue' import Likes from '~/components/Package/Likes.vue' @@ -942,6 +943,115 @@ describe('component accessibility audits', () => { }) }) + describe('FacetQuadrantChart', () => { + it('should have no accessibility violations', async () => { + const wrapper = await mountSuspended(FacetQuadrantChart, { + props: { + packagesData: [ + { + package: { + name: 'vue', + version: '3.5.32', + }, + downloads: 10979552, + packageSize: 2480183, + directDeps: 5, + analysis: { + package: 'vue', + version: '3.5.32', + devDependencySuggestion: { + recommended: false, + }, + moduleFormat: 'dual', + types: { + kind: 'included', + }, + createPackage: { + packageName: 'create-vue', + }, + }, + vulnerabilities: { + count: 0, + severity: { + critical: 0, + high: 0, + moderate: 0, + low: 0, + }, + }, + metadata: { + license: 'MIT', + lastUpdated: '2026-04-03T05:41:39.680Z', + }, + isBinaryOnly: false, + totalLikes: 85, + }, + { + package: { + name: 'svelte', + version: '5.55.1', + }, + downloads: 4378382, + packageSize: 2823272, + directDeps: 16, + analysis: { + package: 'svelte', + version: '5.55.1', + devDependencySuggestion: { + recommended: false, + }, + moduleFormat: 'dual', + types: { + kind: 'included', + }, + engines: { + node: '>=18', + }, + createPackage: { + packageName: 'create-svelte', + deprecated: + 'create-svelte has been deprecated - please use https://www.npmjs.com/package/sv instead', + }, + }, + vulnerabilities: { + count: 0, + severity: { + critical: 0, + high: 0, + moderate: 0, + low: 0, + }, + }, + metadata: { + license: 'MIT', + lastUpdated: '2026-03-29T20:58:44.673Z', + engines: { + node: '>=18', + }, + }, + isBinaryOnly: false, + totalLikes: 191, + }, + ], + packages: ['vue', 'svelte'], + }, + }) + const results = await runAxe(wrapper) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with empty data', async () => { + const wrapper = await mountSuspended(FacetQuadrantChart, { + props: { + packagesData: [], + packages: [], + }, + }) + const results = await runAxe(wrapper) + expect(results.violations).toEqual([]) + }) + }) + it('should have no accessibility violations with empty data', async () => { const wrapper = await mountSuspended(PackageTrendsChart, { props: { diff --git a/test/unit/app/utils/compare-quadrant-chart.spec.ts b/test/unit/app/utils/compare-quadrant-chart.spec.ts new file mode 100644 index 0000000000..37e86bffc0 --- /dev/null +++ b/test/unit/app/utils/compare-quadrant-chart.spec.ts @@ -0,0 +1,626 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { createQuadrantDataset, type PackageQuadrantInput } from '~/utils/compare-quadrant-chart' + +function getPointById(dataset: ReturnType, id: string) { + const point = dataset.find(packagePoint => packagePoint.id === id) + expect(point).toBeDefined() + return point! +} + +describe('createQuadrantDataset', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-05T12:00:00.000Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('returns an empty array when the input is empty', () => { + expect(createQuadrantDataset([])).toEqual([]) + }) + + it('preserves package identity fields', () => { + const input: PackageQuadrantInput[] = [ + { + id: 'pkg-1', + license: 'MIT', + name: 'alpha', + downloads: 100, + }, + ] + + const [point] = createQuadrantDataset(input) + + expect(point).toBeDefined() + expect(point!.id).toBe('pkg-1') + expect(point!.license).toBe('MIT') + expect(point!.name).toBe('alpha') + }) + + it('uses safe defaults for nullable and missing numeric and boolean values', () => { + const input: PackageQuadrantInput[] = [ + { + id: 'pkg-1', + license: 'MIT', + name: 'alpha', + downloads: null, + totalLikes: undefined, + packageSize: null, + installSize: undefined, + dependencies: null, + totalDependencies: undefined, + vulnerabilities: null, + deprecated: null, + types: null, + lastUpdated: null, + }, + { + id: 'pkg-2', + license: 'Apache-2.0', + name: 'beta', + downloads: 10, + totalLikes: 5, + packageSize: 20, + installSize: 30, + dependencies: 2, + totalDependencies: 4, + vulnerabilities: 1, + deprecated: false, + types: true, + lastUpdated: '2026-04-05T12:00:00.000Z', + }, + ] + + const [point] = createQuadrantDataset(input) + + expect(point).toBeDefined() + expect(point!.metrics.downloads).toBe(0) + expect(point!.metrics.totalLikes).toBe(0) + expect(point!.metrics.packageSize).toBe(0) + expect(point!.metrics.installSize).toBe(0) + expect(point!.metrics.dependencies).toBe(0) + expect(point!.metrics.totalDependencies).toBe(0) + expect(point!.metrics.vulnerabilities).toBe(0) + expect(point!.metrics.deprecated).toBe(false) + expect(point!.metrics.types).toBe(false) + expect(point!.metrics.freshnessScore).toBe(-1) + expect(point!.metrics.freshnessPercent).toBe(0) + }) + + it('treats non-finite numeric values as zero', () => { + const input: PackageQuadrantInput[] = [ + { + id: 'pkg-1', + license: 'MIT', + name: 'alpha', + downloads: Number.NaN, + totalLikes: Number.POSITIVE_INFINITY, + packageSize: Number.NEGATIVE_INFINITY, + }, + { + id: 'pkg-2', + license: 'MIT', + name: 'beta', + downloads: 100, + totalLikes: 10, + packageSize: 50, + }, + ] + + const [point] = createQuadrantDataset(input) + + expect(point).toBeDefined() + expect(point!.metrics.downloads).toBe(0) + expect(point!.metrics.totalLikes).toBe(0) + expect(point!.metrics.packageSize).toBe(0) + }) + + it('computes freshness score and percentage from an ISO date string', () => { + const input: PackageQuadrantInput[] = [ + { + id: 'fresh', + license: 'MIT', + name: 'fresh', + lastUpdated: '2026-04-05T12:00:00.000Z', + }, + { + id: 'old', + license: 'MIT', + name: 'old', + lastUpdated: '2025-04-05T12:00:00.000Z', + }, + ] + + const dataset = createQuadrantDataset(input) + const freshPoint = getPointById(dataset, 'fresh') + const oldPoint = getPointById(dataset, 'old') + + expect(freshPoint.metrics.freshnessScore).toBe(1) + expect(freshPoint.metrics.freshnessPercent).toBe(100) + + expect(oldPoint.metrics.freshnessScore).toBe(-1) + expect(oldPoint.metrics.freshnessPercent).toBe(0) + }) + + it('computes freshness from a Date instance', () => { + const input: PackageQuadrantInput[] = [ + { + id: 'fresh', + license: 'MIT', + name: 'fresh', + lastUpdated: new Date('2026-04-05T12:00:00.000Z'), + }, + { + id: 'reference', + license: 'MIT', + name: 'reference', + lastUpdated: new Date('2025-10-05T12:00:00.000Z'), + }, + ] + + const [point] = createQuadrantDataset(input) + + expect(point).toBeDefined() + expect(point!.metrics.freshnessScore).toBe(1) + expect(point!.metrics.freshnessPercent).toBe(100) + }) + + it('returns missing freshness values as score -1 and percent 0 for invalid dates', () => { + const input: PackageQuadrantInput[] = [ + { + id: 'invalid', + license: 'MIT', + name: 'invalid', + lastUpdated: 'not-a-date', + }, + { + id: 'valid', + license: 'MIT', + name: 'valid', + lastUpdated: '2026-04-05T12:00:00.000Z', + }, + ] + + const dataset = createQuadrantDataset(input) + const invalidPoint = getPointById(dataset, 'invalid') + + expect(invalidPoint.metrics.freshnessScore).toBe(-1) + expect(invalidPoint.metrics.freshnessPercent).toBe(0) + }) + + it('forces efficiencyScore to -1 when a package is deprecated', () => { + const input: PackageQuadrantInput[] = [ + { + id: 'deprecated-package', + license: 'MIT', + name: 'deprecated-package', + downloads: 1_000_000, + totalLikes: 500, + packageSize: 1, + installSize: 1, + dependencies: 0, + totalDependencies: 0, + vulnerabilities: 0, + deprecated: true, + types: true, + lastUpdated: '2026-04-05T12:00:00.000Z', + }, + { + id: 'healthy-package', + license: 'MIT', + name: 'healthy-package', + downloads: 10, + totalLikes: 0, + packageSize: 10_000, + installSize: 10_000, + dependencies: 100, + totalDependencies: 1_000, + vulnerabilities: 10, + deprecated: false, + types: false, + lastUpdated: '2025-04-05T12:00:00.000Z', + }, + ] + + const dataset = createQuadrantDataset(input) + const deprecatedPoint = getPointById(dataset, 'deprecated-package') + + expect(deprecatedPoint.metrics.deprecated).toBe(true) + expect(deprecatedPoint.efficiencyScore).toBe(-1) + expect(deprecatedPoint.y).toBe(-1) + expect(deprecatedPoint.quadrant).toMatch(/BOTTOM_/) + }) + + it('rewards typed packages over untyped packages when other metrics are equal', () => { + const input: PackageQuadrantInput[] = [ + { + id: 'typed', + license: 'MIT', + name: 'typed', + downloads: 100, + totalLikes: 10, + packageSize: 50, + installSize: 75, + dependencies: 5, + totalDependencies: 10, + vulnerabilities: 1, + deprecated: false, + types: true, + lastUpdated: '2026-04-05T12:00:00.000Z', + }, + { + id: 'untyped', + license: 'MIT', + name: 'untyped', + downloads: 100, + totalLikes: 10, + packageSize: 50, + installSize: 75, + dependencies: 5, + totalDependencies: 10, + vulnerabilities: 1, + deprecated: false, + types: false, + lastUpdated: '2026-04-05T12:00:00.000Z', + }, + ] + + const dataset = createQuadrantDataset(input) + const typedPoint = getPointById(dataset, 'typed') + const untypedPoint = getPointById(dataset, 'untyped') + + expect(typedPoint.efficiencyScore).toBeGreaterThan(untypedPoint.efficiencyScore) + }) + + it('penalises vulnerabilities more aggressively as they increase', () => { + const input: PackageQuadrantInput[] = [ + { + id: 'secure', + license: 'MIT', + name: 'secure', + downloads: 100, + totalLikes: 10, + packageSize: 50, + installSize: 50, + dependencies: 5, + totalDependencies: 10, + vulnerabilities: 0, + deprecated: false, + types: true, + lastUpdated: '2026-04-05T12:00:00.000Z', + }, + { + id: 'vulnerable', + license: 'MIT', + name: 'vulnerable', + downloads: 100, + totalLikes: 10, + packageSize: 50, + installSize: 50, + dependencies: 5, + totalDependencies: 10, + vulnerabilities: 10, + deprecated: false, + types: true, + lastUpdated: '2026-04-05T12:00:00.000Z', + }, + ] + + const dataset = createQuadrantDataset(input) + const securePoint = getPointById(dataset, 'secure') + const vulnerablePoint = getPointById(dataset, 'vulnerable') + + expect(securePoint.efficiencyScore).toBeGreaterThan(vulnerablePoint.efficiencyScore) + }) + + it('assigns TOP_RIGHT when adoptionScore and efficiencyScore are both non-negative', () => { + const input: PackageQuadrantInput[] = [ + { + id: 'best', + license: 'MIT', + name: 'best', + downloads: 100_000_000, + totalLikes: 1_000, + packageSize: 1, + installSize: 1, + dependencies: 0, + totalDependencies: 0, + vulnerabilities: 0, + deprecated: false, + types: true, + lastUpdated: '2026-04-05T12:00:00.000Z', + }, + { + id: 'worst', + license: 'MIT', + name: 'worst', + downloads: 1, + totalLikes: 0, + packageSize: 15_000_000, + installSize: 25_000_000, + dependencies: 100, + totalDependencies: 1_000, + vulnerabilities: 10, + deprecated: false, + types: false, + lastUpdated: '2025-04-05T12:00:00.000Z', + }, + ] + + const dataset = createQuadrantDataset(input) + const point = getPointById(dataset, 'best') + + expect(point.adoptionScore).toBeGreaterThanOrEqual(0) + expect(point.efficiencyScore).toBeGreaterThanOrEqual(0) + expect(point.quadrant).toBe('TOP_RIGHT') + expect(point.x).toBe(point.adoptionScore) + expect(point.y).toBe(point.efficiencyScore) + }) + + it('assigns TOP_LEFT when adoptionScore is negative and efficiencyScore is non-negative', () => { + const input: PackageQuadrantInput[] = [ + { + id: 'efficient-but-unadopted', + license: 'MIT', + name: 'efficient-but-unadopted', + downloads: 1, + totalLikes: 0, + packageSize: 1, + installSize: 1, + dependencies: 0, + totalDependencies: 0, + vulnerabilities: 0, + deprecated: false, + types: true, + lastUpdated: '2026-04-05T12:00:00.000Z', + }, + { + id: 'popular-and-heavy', + license: 'MIT', + name: 'popular-and-heavy', + downloads: 100_000_000, + totalLikes: 1_000, + packageSize: 15_000_000, + installSize: 25_000_000, + dependencies: 100, + totalDependencies: 1_000, + vulnerabilities: 10, + deprecated: false, + types: false, + lastUpdated: '2025-04-05T12:00:00.000Z', + }, + ] + + const dataset = createQuadrantDataset(input) + const point = getPointById(dataset, 'efficient-but-unadopted') + + expect(point.adoptionScore).toBeLessThan(0) + expect(point.efficiencyScore).toBeGreaterThanOrEqual(0) + expect(point.quadrant).toBe('TOP_LEFT') + }) + + it('assigns BOTTOM_RIGHT when adoptionScore is non-negative and efficiencyScore is negative', () => { + const input: PackageQuadrantInput[] = [ + { + id: 'popular-but-inefficient', + license: 'MIT', + name: 'popular-but-inefficient', + downloads: 100_000_000, + totalLikes: 1_000, + packageSize: 15_000_000, + installSize: 25_000_000, + dependencies: 100, + totalDependencies: 1_000, + vulnerabilities: 10, + deprecated: false, + types: false, + lastUpdated: '2026-04-05T12:00:00.000Z', + }, + { + id: 'niche-but-efficient', + license: 'MIT', + name: 'niche-but-efficient', + downloads: 1, + totalLikes: 0, + packageSize: 1, + installSize: 1, + dependencies: 0, + totalDependencies: 0, + vulnerabilities: 0, + deprecated: false, + types: true, + lastUpdated: '2025-04-05T12:00:00.000Z', + }, + ] + + const dataset = createQuadrantDataset(input) + const point = getPointById(dataset, 'popular-but-inefficient') + + expect(point.adoptionScore).toBeGreaterThanOrEqual(0) + expect(point.efficiencyScore).toBeLessThan(0) + expect(point.quadrant).toBe('BOTTOM_RIGHT') + }) + + it('assigns BOTTOM_LEFT when adoptionScore and efficiencyScore are both negative', () => { + const input: PackageQuadrantInput[] = [ + { + id: 'worst', + license: 'MIT', + name: 'worst', + downloads: 1, + totalLikes: 0, + packageSize: 15_000_000, + installSize: 25_000_000, + dependencies: 100, + totalDependencies: 1_000, + vulnerabilities: 10, + deprecated: false, + types: false, + lastUpdated: '2025-04-05T12:00:00.000Z', + }, + { + id: 'best', + license: 'MIT', + name: 'best', + downloads: 100_000_000, + totalLikes: 1_000, + packageSize: 1, + installSize: 1, + dependencies: 0, + totalDependencies: 0, + vulnerabilities: 0, + deprecated: false, + types: true, + lastUpdated: '2026-04-05T12:00:00.000Z', + }, + ] + + const dataset = createQuadrantDataset(input) + const point = getPointById(dataset, 'worst') + + expect(point.adoptionScore).toBeLessThan(0) + expect(point.efficiencyScore).toBeLessThan(0) + expect(point.quadrant).toBe('BOTTOM_LEFT') + }) + + it('uses logarithmic normalization so larger download counts still improve adoption score across orders of magnitude', () => { + const input: PackageQuadrantInput[] = [ + { + id: 'small', + license: 'MIT', + name: 'small', + downloads: 10, + totalLikes: 0, + lastUpdated: '2026-04-05T12:00:00.000Z', + }, + { + id: 'medium', + license: 'MIT', + name: 'medium', + downloads: 1_000, + totalLikes: 0, + lastUpdated: '2026-04-05T12:00:00.000Z', + }, + { + id: 'large', + license: 'MIT', + name: 'large', + downloads: 1_000_000, + totalLikes: 0, + lastUpdated: '2026-04-05T12:00:00.000Z', + }, + ] + + const dataset = createQuadrantDataset(input) + const small = getPointById(dataset, 'small') + const medium = getPointById(dataset, 'medium') + const large = getPointById(dataset, 'large') + + expect(small.adoptionScore).toBeLessThan(medium.adoptionScore) + expect(medium.adoptionScore).toBeLessThan(large.adoptionScore) + }) + + it('penalises larger install sizes when other efficiency metrics are equal', () => { + const input: PackageQuadrantInput[] = [ + { + id: 'small-install', + license: 'MIT', + name: 'small-install', + downloads: 1_000, + totalLikes: 10, + packageSize: 10_000, + installSize: 50_000, + dependencies: 10, + totalDependencies: 50, + vulnerabilities: 0, + deprecated: false, + types: true, + lastUpdated: '2026-04-05T12:00:00.000Z', + }, + { + id: 'large-install', + license: 'MIT', + name: 'large-install', + downloads: 1_000, + totalLikes: 10, + packageSize: 10_000, + installSize: 10_000_000, + dependencies: 10, + totalDependencies: 50, + vulnerabilities: 0, + deprecated: false, + types: true, + lastUpdated: '2026-04-05T12:00:00.000Z', + }, + ] + + const dataset = createQuadrantDataset(input) + const smallInstall = getPointById(dataset, 'small-install') + const largeInstall = getPointById(dataset, 'large-install') + + expect(smallInstall.efficiencyScore).toBeGreaterThan(largeInstall.efficiencyScore) + }) + + it('returns one point per input package and keeps the input order', () => { + const input: PackageQuadrantInput[] = [ + { id: 'one', license: 'MIT', name: 'one', downloads: 1 }, + { id: 'two', license: 'MIT', name: 'two', downloads: 2 }, + { id: 'three', license: 'MIT', name: 'three', downloads: 3 }, + ] + + const dataset = createQuadrantDataset(input) + + expect(dataset).toHaveLength(3) + expect(dataset.map(point => point.id)).toEqual(['one', 'two', 'three']) + }) + + it('clamps scores to the [-1, 1] range', () => { + const input: PackageQuadrantInput[] = [ + { + id: 'extreme-best', + license: 'MIT', + name: 'extreme-best', + downloads: 10_000_000_000, + totalLikes: 10_000, + packageSize: 1, + installSize: 1, + dependencies: 0, + totalDependencies: 0, + vulnerabilities: 0, + deprecated: false, + types: true, + lastUpdated: '2026-04-05T12:00:00.000Z', + }, + { + id: 'extreme-worst', + license: 'MIT', + name: 'extreme-worst', + downloads: 0, + totalLikes: 0, + packageSize: 100_000_000, + installSize: 100_000_000, + dependencies: 10_000, + totalDependencies: 20_000, + vulnerabilities: 10_000, + deprecated: false, + types: false, + lastUpdated: '2024-04-05T12:00:00.000Z', + }, + ] + + const dataset = createQuadrantDataset(input) + + for (const point of dataset) { + expect(point.adoptionScore).toBeGreaterThanOrEqual(-1) + expect(point.adoptionScore).toBeLessThanOrEqual(1) + expect(point.efficiencyScore).toBeGreaterThanOrEqual(-1) + expect(point.efficiencyScore).toBeLessThanOrEqual(1) + expect(point.x).toBeGreaterThanOrEqual(-1) + expect(point.x).toBeLessThanOrEqual(1) + expect(point.y).toBeGreaterThanOrEqual(-1) + expect(point.y).toBeLessThanOrEqual(1) + } + }) +})