From 9f780bba0939fe153bb439678fff1e532536e1dd Mon Sep 17 00:00:00 2001 From: graphieros Date: Sun, 5 Apr 2026 10:14:10 +0200 Subject: [PATCH 01/35] chore: bump vue-data-ui from 3.17.3 to 3.17.8 --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 6b8a0bcff7..d2f5a623dc 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,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.8" }, "devDependencies": { "@e18e/eslint-plugin": "0.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22198914ee..3c12ec4bae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,8 +232,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.8 + version: 3.17.8(vue@3.5.30(typescript@6.0.2)) devDependencies: '@e18e/eslint-plugin': specifier: 0.3.0 @@ -10718,8 +10718,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.8: + resolution: {integrity: sha512-LlMQHe555sCAwPiPfYE7yerCBs5e71f50nOpc5I4A0ouCL1M78TZSx/1ggTJnYIEILyaTTPlnZ4jCjVM8Z6Fdg==} peerDependencies: jspdf: '>=3.0.1' vue: '>=3.3.0' @@ -23597,7 +23597,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.8(vue@3.5.30(typescript@6.0.2)): dependencies: vue: 3.5.30(typescript@6.0.2) From 88dce54a82a3b92f59abcb4f7776b8cc33d1ffd0 Mon Sep 17 00:00:00 2001 From: graphieros Date: Sun, 5 Apr 2026 10:28:16 +0200 Subject: [PATCH 02/35] feat: add quadrant chart utility --- app/utils/compare-quadrant-chart.ts | 301 +++++++++ .../app/utils/compare-quadrant-chart.spec.ts | 621 ++++++++++++++++++ 2 files changed, 922 insertions(+) create mode 100644 app/utils/compare-quadrant-chart.ts create mode 100644 test/unit/app/utils/compare-quadrant-chart.spec.ts diff --git a/app/utils/compare-quadrant-chart.ts b/app/utils/compare-quadrant-chart.ts new file mode 100644 index 0000000000..05105de50f --- /dev/null +++ b/app/utils/compare-quadrant-chart.ts @@ -0,0 +1,301 @@ +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 + } +} + +interface QuadrantMetricRanges { + minimumDownloads: number + maximumDownloads: number + minimumTotalLikes: number + maximumTotalLikes: number + minimumPackageSize: number + maximumPackageSize: number + minimumInstallSize: number + maximumInstallSize: number + minimumDependencies: number + maximumDependencies: number + minimumTotalDependencies: number + maximumTotalDependencies: number + minimumVulnerabilities: number + maximumVulnerabilities: number + minimumLogarithmicDownloads: number + maximumLogarithmicDownloads: number +} + +const WEIGHTS = { + // Quadrant X axis + adoption: { + downloads: 0.7, // dominant signal because they best reflect real-world adoption + freshness: 0.1, // small correction so stale packages are slightly penalized + likes: 0.01, // might be pumped up in the future when ./npmx likes are more mainstream + }, + // Quadrant Y axis + efficiency: { + installSize: 0.3, // weighted highest because it best reflects consumer footprint + dependencies: 0.2, // direct deps capture architectural and supply-chain complexity + totalDependencies: 0.15, // same for total deps + packageSize: 0.1, // publication weight, less important than installed footprint + vulnerabilities: 0.2, // penalize security burden + types: 0.1, // TS support + deprecation: 0.05 + } +} +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 normalizeNumber(value: number, min: number, max: number): number { + if (max === min) return 0 + const normalisedValue = (value - min) / (max - min) + return clampInRange(normalisedValue * 2 - 1) +} + +function normalizeInverseNumber(value: number, min: number, max: number): number { + return -normalizeNumber(value, min, max) +} + +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 getVulnerabilityPenalty( + value: number, + minimum: number, + maximum: number, +): number { + const normalised = normalizeInverseNumber(value, minimum, maximum) + return normalised < 0 ? normalised * VULNERABILITY_PENALTY_MULTIPLIER : normalised +} + +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 getQuadrantMetricRanges(packages: PackageQuadrantInput[]): QuadrantMetricRanges { + const downloadsValues = packages.map(packageItem => toSafeNumber(packageItem.downloads)) + const totalLikesValues = packages.map(packageItem => toSafeNumber(packageItem.totalLikes)) + const packageSizeValues = packages.map(packageItem => toSafeNumber(packageItem.packageSize)) + const installSizeValues = packages.map(packageItem => toSafeNumber(packageItem.installSize)) + const dependenciesValues = packages.map(packageItem => toSafeNumber(packageItem.dependencies)) + const totalDependenciesValues = packages.map(packageItem => + toSafeNumber(packageItem.totalDependencies), + ) + const vulnerabilitiesValues = packages.map(packageItem => + toSafeNumber(packageItem.vulnerabilities), + ) + const logarithmicDownloadsValues = downloadsValues.map(value => Math.log(value + 1)) + + return { + minimumDownloads: Math.min(...downloadsValues), + maximumDownloads: Math.max(...downloadsValues), + minimumTotalLikes: Math.min(...totalLikesValues), + maximumTotalLikes: Math.max(...totalLikesValues), + minimumPackageSize: Math.min(...packageSizeValues), + maximumPackageSize: Math.max(...packageSizeValues), + minimumInstallSize: Math.min(...installSizeValues), + maximumInstallSize: Math.max(...installSizeValues), + minimumDependencies: Math.min(...dependenciesValues), + maximumDependencies: Math.max(...dependenciesValues), + minimumTotalDependencies: Math.min(...totalDependenciesValues), + maximumTotalDependencies: Math.max(...totalDependenciesValues), + minimumVulnerabilities: Math.min(...vulnerabilitiesValues), + maximumVulnerabilities: Math.max(...vulnerabilitiesValues), + minimumLogarithmicDownloads: Math.min(...logarithmicDownloadsValues), + maximumLogarithmicDownloads: Math.max(...logarithmicDownloadsValues), + } +} + +function createQuadrantPoint( + packageItem: PackageQuadrantInput, + metricRanges: QuadrantMetricRanges, +): 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 + + // 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 + const normalisedDownloads = normalizeNumber( + Math.log(downloads + 1), + metricRanges.minimumLogarithmicDownloads, + metricRanges.maximumLogarithmicDownloads, + ) + + const normalisedTotalLikes = normalizeNumber( + totalLikes, + metricRanges.minimumTotalLikes, + metricRanges.maximumTotalLikes, + ) + + const normalisedInstallSize = normalizeInverseNumber( + installSize, + metricRanges.minimumInstallSize, + metricRanges.maximumInstallSize, + ) + + const normalisedDependencies = normalizeInverseNumber( + dependencies, + metricRanges.minimumDependencies, + metricRanges.maximumDependencies, + ) + + const normalisedTotalDependencies = normalizeInverseNumber( + totalDependencies, + metricRanges.minimumTotalDependencies, + metricRanges.maximumTotalDependencies, + ) + + const normalisedPackageSize = normalizeInverseNumber( + packageSize, + metricRanges.minimumPackageSize, + metricRanges.maximumPackageSize, + ) + + const normalisedVulnerabilities = getVulnerabilityPenalty( + vulnerabilities, + metricRanges.minimumVulnerabilities, + metricRanges.maximumVulnerabilities, + ) + + const deprecationScore = normalizeBoolean(!deprecated) + const typesScore = normalizeBoolean(types) + + const adoptionScore = clampInRange( + normalisedDownloads * WEIGHTS.adoption.downloads + + freshnessScore * WEIGHTS.adoption.freshness + + normalisedTotalLikes * 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 + + deprecationScore * WEIGHTS.efficiency.deprecation + +// Deprecation considered harmful +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[] { + if (!packages.length) return [] + const metricRanges = getQuadrantMetricRanges(packages) + return packages.map(packageItem => createQuadrantPoint(packageItem, metricRanges)) +} \ No newline at end of file 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..e870e4a057 --- /dev/null +++ b/test/unit/app/utils/compare-quadrant-chart.spec.ts @@ -0,0 +1,621 @@ +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: 1_000, + installSize: 1_000, + dependencies: 50, + totalDependencies: 100, + 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', + }, + { + id: 'range-anchor', + license: 'MIT', + name: 'range-anchor', + downloads: 200, + totalLikes: 20, + packageSize: 100, + installSize: 150, + dependencies: 10, + totalDependencies: 20, + vulnerabilities: 2, + deprecated: false, + types: true, + lastUpdated: '2025-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('penalizes vulnerabilities more aggressively than a standard inverse normalization', () => { + 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', + }, + { + id: 'range-anchor', + license: 'MIT', + name: 'range-anchor', + downloads: 200, + totalLikes: 20, + packageSize: 100, + installSize: 100, + dependencies: 10, + totalDependencies: 20, + vulnerabilities: 5, + deprecated: false, + types: true, + lastUpdated: '2025-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: 1_000_000, + totalLikes: 500, + packageSize: 10, + installSize: 10, + 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: 1_000, + installSize: 1_000, + dependencies: 100, + totalDependencies: 200, + 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: 1_000_000, + totalLikes: 500, + packageSize: 1_000, + installSize: 1_000, + dependencies: 100, + totalDependencies: 200, + 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: 1_000_000, + totalLikes: 500, + packageSize: 1_000, + installSize: 1_000, + dependencies: 100, + totalDependencies: 200, + 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: 1_000, + installSize: 1_000, + dependencies: 100, + totalDependencies: 200, + vulnerabilities: 10, + deprecated: false, + types: false, + lastUpdated: '2025-04-05T12:00:00.000Z', + }, + { + id: 'best', + license: 'MIT', + name: 'best', + downloads: 1_000_000, + totalLikes: 500, + 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('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, + 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, + installSize: 100_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) + } + }) +}) \ No newline at end of file From 3174980c140d6bac64d89219ae91f4b25de5cba8 Mon Sep 17 00:00:00 2001 From: graphieros Date: Sun, 5 Apr 2026 10:28:48 +0200 Subject: [PATCH 03/35] feat: make watermark generator more flexible --- app/composables/useChartWatermark.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/app/composables/useChartWatermark.ts b/app/composables/useChartWatermark.ts index 48e6adf96c..e7c6ebdbfc 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,18 @@ 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')} From 380519109cf9741b74bf2fb52a46fcf4a54443a8 Mon Sep 17 00:00:00 2001 From: graphieros Date: Sun, 5 Apr 2026 10:29:26 +0200 Subject: [PATCH 04/35] feat: add facet quadrant chart to compare page --- app/components/Compare/FacetQuadrantChart.vue | 495 ++++++++++++++++++ app/pages/compare.vue | 8 +- app/utils/charts.ts | 63 +++ test/nuxt/a11y.spec.ts | 111 +++- 4 files changed, 675 insertions(+), 2 deletions(-) create mode 100644 app/components/Compare/FacetQuadrantChart.vue diff --git a/app/components/Compare/FacetQuadrantChart.vue b/app/components/Compare/FacetQuadrantChart.vue new file mode 100644 index 0000000000..25b0ab9021 --- /dev/null +++ b/app/components/Compare/FacetQuadrantChart.vue @@ -0,0 +1,495 @@ + + + + + \ No newline at end of file diff --git a/app/pages/compare.vue b/app/pages/compare.vue index 5f9bf00c0f..347ca79238 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', @@ -272,6 +273,7 @@ useSeoMeta({
+ - +
{{ $t('compare.packages.no_chartable_data') }}

+ diff --git a/app/utils/charts.ts b/app/utils/charts.ts index 6997b8c025..45cbc72720 100644 --- a/app/utils/charts.ts +++ b/app/utils/charts.ts @@ -2,6 +2,7 @@ import type { AltCopyArgs, VueUiHorizontalBarConfig, VueUiHorizontalBarDatapoint, + VueUiQuadrantDatapoint, VueUiXyConfig, VueUiXyDatasetBarItem, VueUiXyDatasetLineItem, @@ -636,6 +637,68 @@ export async function copyAltTextForCompareFacetBarChart({ await config.copy(altText) } +export function createAltTextForCompareQuadrantChart({ + dataset, + config +}: AltCopyArgs) { + 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/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 46bba7bba1..11032f98b0 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -66,7 +66,7 @@ const allowedWarnings: RegExp[] = [ ] beforeEach(() => { - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }) }) afterEach(() => { @@ -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,114 @@ 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: { From 85e6ac869a0653da32f275e3dcdf1d084b5cae73 Mon Sep 17 00:00:00 2001 From: graphieros Date: Sun, 5 Apr 2026 10:32:30 +0200 Subject: [PATCH 05/35] chore: add translations --- i18n/locales/en.json | 24 ++++++++++++++ i18n/locales/fr-FR.json | 24 ++++++++++++++ i18n/schema.json | 72 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/i18n/locales/en.json b/i18n/locales/en.json index c073827c3f..90b54b2f9a 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -1122,6 +1122,30 @@ "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": "resource-heavy", + "label_bottom_left": "low-impact", + "label_top_left": "efficient", + "title": "Adoption vs Efficiency matrix", + "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 (efficient): {packages}", + "side_analysis_bottom_right": "The following packages are positioned on the bottom-right quadrant (resource-heavy): {packages}", + "side_analysis_bottom_left": "The following packages are positioned on the bottom-right quadrant (low-impact): {packages}" + }, + "explanation": { + "introduction": "The score is calculated by combining multiple signals into two axes:", + "adoption": "Adoption: how widely used and actively maintained a package is", + "efficiency": "Efficiency: how lightweight, safe, and maintainable a package is", + "impact_details": "Each metric contributes differently to the final score. Key factors like downloads and install size have the biggest impact, while others refine the result. Some signals (like 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..767de4eb83 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -1118,6 +1118,30 @@ "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é", + "label_top_right": "fort impact", + "label_bottom_right": "gourmand en ressources", + "label_bottom_left": "faible impact", + "label_top_left": "efficient", + "title": "Matrice Adoption vs Efficacité", + "filename": "matrice-adoption-vs-efficacite-des-paquets", + "label_freshness_score": "Score de fraîcheur", + "copy_alt": { + "description": "Graphique en quadrants représentant l’adoption par rapport à l’efficacité pour les paquets {packages}. {analysis}. {watermark}.", + "side_analysis_top_right": "Les paquets suivants se trouvent dans le quadrant supérieur droit (fort impact) : {packages}", + "side_analysis_top_left": "Les paquets suivants se trouvent dans le quadrant supérieur gauche (efficient) : {packages}", + "side_analysis_bottom_right": "Les paquets suivants se trouvent dans le quadrant inférieur droit (gourmand en ressources) : {packages}", + "side_analysis_bottom_left": "Les paquets suivants se trouvent dans le quadrant inférieur droit (faible impact) : {packages}" + }, + "explanation": { + "introduction": "Le score est calculé en combinant plusieurs signaux sur deux axes :", + "adoption": "Adoption : à quel point un package est utilisé et activement maintenu", + "efficiency": "Efficacité : à quel point un package est léger, sûr et facile à maintenir", + "impact_details": "Chaque métrique contribue différemment au score final. Les facteurs clés comme les téléchargements et la taille d’installation ont le plus d’impact, tandis que d’autres affinent le résultat. Certains signaux, comme les vulnérabilités ou l’obsolescence, 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..6163f253a7 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -3370,6 +3370,78 @@ }, "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": { + "introduction": { + "type": "string" + }, + "adoption": { + "type": "string" + }, + "efficiency": { + "type": "string" + }, + "impact_details": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, "no_dependency": { "type": "object", "properties": { From e4dff84ebbbbc7f93d1dc01ba3a76ca5dd851f12 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 08:48:21 +0000 Subject: [PATCH 06/35] [autofix.ci] apply automated fixes --- app/components/Compare/FacetQuadrantChart.vue | 181 ++++++++---------- app/composables/useChartWatermark.ts | 6 +- app/pages/compare.vue | 6 +- app/utils/charts.ts | 35 ++-- app/utils/compare-quadrant-chart.ts | 38 ++-- test/nuxt/a11y.spec.ts | 69 +++---- .../app/utils/compare-quadrant-chart.spec.ts | 14 +- 7 files changed, 164 insertions(+), 185 deletions(-) diff --git a/app/components/Compare/FacetQuadrantChart.vue b/app/components/Compare/FacetQuadrantChart.vue index 25b0ab9021..ae67d5f071 100644 --- a/app/components/Compare/FacetQuadrantChart.vue +++ b/app/components/Compare/FacetQuadrantChart.vue @@ -1,36 +1,36 @@