From 7d41c4449a581e379384bb2150493a46841a2ddd Mon Sep 17 00:00:00 2001 From: graphieros Date: Sat, 11 Apr 2026 10:39:33 +0200 Subject: [PATCH 01/19] chore: remove quadrant chart --- app/components/Compare/FacetQuadrantChart.vue | 547 --------------- app/utils/compare-quadrant-chart.ts | 221 ------- .../app/utils/compare-quadrant-chart.spec.ts | 626 ------------------ 3 files changed, 1394 deletions(-) delete mode 100644 app/components/Compare/FacetQuadrantChart.vue delete mode 100644 app/utils/compare-quadrant-chart.ts delete mode 100644 test/unit/app/utils/compare-quadrant-chart.spec.ts diff --git a/app/components/Compare/FacetQuadrantChart.vue b/app/components/Compare/FacetQuadrantChart.vue deleted file mode 100644 index 0dc829c847..0000000000 --- a/app/components/Compare/FacetQuadrantChart.vue +++ /dev/null @@ -1,547 +0,0 @@ - - - - - diff --git a/app/utils/compare-quadrant-chart.ts b/app/utils/compare-quadrant-chart.ts deleted file mode 100644 index 97d9879812..0000000000 --- a/app/utils/compare-quadrant-chart.ts +++ /dev/null @@ -1,221 +0,0 @@ -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/test/unit/app/utils/compare-quadrant-chart.spec.ts b/test/unit/app/utils/compare-quadrant-chart.spec.ts deleted file mode 100644 index 37e86bffc0..0000000000 --- a/test/unit/app/utils/compare-quadrant-chart.spec.ts +++ /dev/null @@ -1,626 +0,0 @@ -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) - } - }) -}) From ba3042ed22440c17991d52ddb7b7e6ead7196a01 Mon Sep 17 00:00:00 2001 From: graphieros Date: Sat, 11 Apr 2026 10:40:04 +0200 Subject: [PATCH 02/19] chore: bump vue-data-ui from 3.17.11 to 3.17.12 --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 700a80465a..b3f9c20449 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "vite-plugin-pwa": "1.2.0", "vite-plus": "0.1.16", "vue": "3.5.30", - "vue-data-ui": "3.17.11" + "vue-data-ui": "3.17.12" }, "devDependencies": { "@e18e/eslint-plugin": "0.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80104d60ca..42ed551988 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -233,8 +233,8 @@ importers: specifier: 3.5.30 version: 3.5.30(typescript@6.0.2) vue-data-ui: - specifier: 3.17.11 - version: 3.17.11(vue@3.5.30) + specifier: 3.17.12 + version: 3.17.12(vue@3.5.30) devDependencies: '@e18e/eslint-plugin': specifier: 0.3.0 @@ -10669,8 +10669,8 @@ packages: vue-component-type-helpers@3.2.6: resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==} - vue-data-ui@3.17.11: - resolution: {integrity: sha512-ZwKPjdg8a5FXaVJpXx+nHGfBwlL/qZqOk+9y3Pk7OJxKz3th0S6h/ANvwiAAVnLYlp1wKDzDuXm6am//+bUWRg==} + vue-data-ui@3.17.12: + resolution: {integrity: sha512-a5l+AdYdd70pz3gI1Jl/WaL+UNZMSEjHQ961PHUE7M2s6fL/W3GRCC3CuUY5eTNrJX0DRq9ZV+CG75MzivHojg==} peerDependencies: jspdf: '>=3.0.1' vue: '>=3.3.0' @@ -22940,7 +22940,7 @@ snapshots: vue-component-type-helpers@3.2.6: {} - vue-data-ui@3.17.11(vue@3.5.30): + vue-data-ui@3.17.12(vue@3.5.30): dependencies: vue: 3.5.30(typescript@6.0.2) From c8d70262fc3ecff4b6ce22ad8f498833ab16eb10 Mon Sep 17 00:00:00 2001 From: graphieros Date: Sat, 11 Apr 2026 10:43:49 +0200 Subject: [PATCH 03/19] feat: add compare scatter chart --- app/components/Compare/FacetScatterChart.vue | 490 ++++++++ app/composables/useChartWatermark.ts | 16 +- app/composables/useFacetSelection.ts | 40 +- app/pages/compare.vue | 8 +- app/utils/charts.ts | 96 +- app/utils/compare-scatter-chart.ts | 121 ++ nuxt.config.ts | 2 +- test/nuxt/a11y.spec.ts | 22 +- .../app/utils/compare-scatter-chart.spec.ts | 1058 +++++++++++++++++ 9 files changed, 1775 insertions(+), 78 deletions(-) create mode 100644 app/components/Compare/FacetScatterChart.vue create mode 100644 app/utils/compare-scatter-chart.ts create mode 100644 test/unit/app/utils/compare-scatter-chart.spec.ts diff --git a/app/components/Compare/FacetScatterChart.vue b/app/components/Compare/FacetScatterChart.vue new file mode 100644 index 0000000000..40067d4e4c --- /dev/null +++ b/app/components/Compare/FacetScatterChart.vue @@ -0,0 +1,490 @@ + + + + + diff --git a/app/composables/useChartWatermark.ts b/app/composables/useChartWatermark.ts index c927bae769..f497bde3b3 100644 --- a/app/composables/useChartWatermark.ts +++ b/app/composables/useChartWatermark.ts @@ -132,27 +132,35 @@ export function drawSmallNpmxLogoAndTaglineWatermark({ colors, translateFn, logoWidth = 36, + taglineFontSize = 8, + offsetYTagline = 0, + offsetXTagline = 0, + offsetYLogo = 0, }: { svg: Record colors: WatermarkColors translateFn: (key: string) => string logoWidth?: number + taglineFontSize?: number + offsetYTagline?: number + offsetXTagline?: number + offsetYLogo?: number }) { if (!svg.height) return const npmxLogoWidthToHeight = 2.64 const npmxLogoHeight = logoWidth / npmxLogoWidthToHeight const offsetX = 6 - const watermarkY = svg.height - npmxLogoHeight + const watermarkY = svg.height - npmxLogoHeight + offsetYLogo const taglineY = svg.height - 3 return ` ${generateWatermarkLogo({ x: offsetX, y: watermarkY, width: logoWidth, height: npmxLogoHeight, fill: colors.fg })} ${translateFn('tagline')} diff --git a/app/composables/useFacetSelection.ts b/app/composables/useFacetSelection.ts index 92875037e9..368123c1ed 100644 --- a/app/composables/useFacetSelection.ts +++ b/app/composables/useFacetSelection.ts @@ -4,6 +4,8 @@ export interface FacetInfoWithLabels extends Omit { label: string description: string chartable: boolean + chartable_scatter: boolean + formatter?: (value: number) => string } // Get facets in a category (excluding coming soon) @@ -21,73 +23,104 @@ function getFacetsInCategory(category: string): ComparisonFacet[] { */ export function useFacetSelection(queryParam = 'facets') { const { t } = useI18n() + const compactNumberFormatter = useCompactNumberFormatter() + const bytesFormatter = useBytesFormatter() const facetLabels = computed( - (): Record => ({ + (): Record< + ComparisonFacet, + { + label: string + description: string + chartable: boolean + chartable_scatter: boolean + formatter?: (value: number) => string + } + > => ({ downloads: { label: t(`compare.facets.items.downloads.label`), description: t(`compare.facets.items.downloads.description`), - chartable: true, + chartable: true, // TODO: rename to chartable_bar + chartable_scatter: true, + formatter: v => compactNumberFormatter.value.format(v), }, totalLikes: { label: t(`compare.facets.items.totalLikes.label`), description: t(`compare.facets.items.totalLikes.description`), chartable: true, + chartable_scatter: true, + formatter: v => compactNumberFormatter.value.format(v), }, packageSize: { label: t(`compare.facets.items.packageSize.label`), description: t(`compare.facets.items.packageSize.description`), chartable: true, + chartable_scatter: true, + formatter: v => bytesFormatter.format(v), }, installSize: { label: t(`compare.facets.items.installSize.label`), description: t(`compare.facets.items.installSize.description`), chartable: true, + chartable_scatter: true, + formatter: v => bytesFormatter.format(v), }, moduleFormat: { label: t(`compare.facets.items.moduleFormat.label`), description: t(`compare.facets.items.moduleFormat.description`), chartable: false, + chartable_scatter: false, }, types: { label: t(`compare.facets.items.types.label`), description: t(`compare.facets.items.types.description`), chartable: false, + chartable_scatter: false, }, engines: { label: t(`compare.facets.items.engines.label`), description: t(`compare.facets.items.engines.description`), chartable: false, + chartable_scatter: false, }, vulnerabilities: { label: t(`compare.facets.items.vulnerabilities.label`), description: t(`compare.facets.items.vulnerabilities.description`), chartable: false, + chartable_scatter: true, + formatter: v => compactNumberFormatter.value.format(v), }, lastUpdated: { label: t(`compare.facets.items.lastUpdated.label`), description: t(`compare.facets.items.lastUpdated.description`), chartable: false, + chartable_scatter: true, }, license: { label: t(`compare.facets.items.license.label`), description: t(`compare.facets.items.license.description`), chartable: false, + chartable_scatter: false, }, dependencies: { label: t(`compare.facets.items.dependencies.label`), description: t(`compare.facets.items.dependencies.description`), chartable: true, + chartable_scatter: true, + formatter: v => compactNumberFormatter.value.format(v), }, totalDependencies: { label: t(`compare.facets.items.totalDependencies.label`), description: t(`compare.facets.items.totalDependencies.description`), chartable: true, + chartable_scatter: true, + formatter: v => compactNumberFormatter.value.format(v), }, deprecated: { label: t(`compare.facets.items.deprecated.label`), description: t(`compare.facets.items.deprecated.description`), chartable: false, + chartable_scatter: false, }, }), ) @@ -100,6 +133,8 @@ export function useFacetSelection(queryParam = 'facets') { label: facetLabels.value[facet].label, description: facetLabels.value[facet].description, chartable: facetLabels.value[facet].chartable, + chartable_scatter: facetLabels.value[facet].chartable_scatter, + formatter: facetLabels.value[facet].formatter ?? undefined, } } @@ -224,6 +259,7 @@ export function useFacetSelection(queryParam = 'facets') { isAllSelected, isNoneSelected, allFacets: ALL_FACETS, + facetLabels, // Facet info with i18n getCategoryLabel, facetsByCategory, diff --git a/app/pages/compare.vue b/app/pages/compare.vue index 9c4a4d2fee..4dd395d008 100644 --- a/app/pages/compare.vue +++ b/app/pages/compare.vue @@ -2,8 +2,8 @@ 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' import type { CommandPaletteContextCommandInput } from '~/types/command-palette' +import FacetScatterChart from '~/components/Compare/FacetScatterChart.vue' definePageMeta({ name: 'compare', @@ -424,7 +424,7 @@ useSeoMeta({ - +
{{ $t('compare.packages.no_chartable_data') }}

-
- + Promise $t: TrendTranslateFunction + x: { + label: string + formatter: (v: number) => string + } + y: { + label: string + formatter: (v: number) => string + } } -// Used for FacetQuadrantChart.vue -export function createAltTextForCompareQuadrantChart({ +// Used for FacetScatterChart.vue +export function createAltTextForCompareScatterChart({ dataset, config, -}: AltCopyArgs) { +}: 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 { x, y } = config + const { label: labelX, formatter: formatterX } = x + const { label: labelY, formatter: formatterY } = y - const descriptions = { - topRight: '', - topLeft: '', - bottomRight: '', - bottomLeft: '', - } + const datapoints = dataset.map(d => { + const rawX = d.values?.[0]?.x ?? 0 + const rawY = d.values?.[0]?.y ?? 0 + const name = d.fullName ?? '' - 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(', '), - }, - ) - } + return { + x: formatterX(rawX), + y: formatterY(rawY), + name, + } + }) - 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 = datapoints + .map(d => + config.$t('compare.scatter_chart.copy_alt.analysis', { + package: d.name, + x_name: labelX, + y_name: labelY, + x_value: d.x, + y_value: d.y, + }), ) - } - - const analysis = Object.values(descriptions).filter(Boolean).join('. ') + .join(', ') - const altText = config.$t('compare.quadrant_chart.copy_alt.description', { - packages: dataset.map(p => p.fullname).join(', '), + const altText = config.$t('compare.scatter_chart.copy_alt.description', { + x_name: labelX, + y_name: labelY, + packages: datapoints.map(d => d.name).join(', '), analysis, watermark: config.$t('package.trends.copy_alt.watermark'), }) @@ -705,11 +697,11 @@ export function createAltTextForCompareQuadrantChart({ return altText } -export async function copyAltTextForCompareQuadrantChart({ +export async function coopyAltTextForCompareScatterChart({ dataset, config, -}: AltCopyArgs) { - const altText = createAltTextForCompareQuadrantChart({ dataset, config }) +}: AltCopyArgs) { + const altText = createAltTextForCompareScatterChart({ dataset, config }) await config.copy(altText) } diff --git a/app/utils/compare-scatter-chart.ts b/app/utils/compare-scatter-chart.ts new file mode 100644 index 0000000000..7fb54c9000 --- /dev/null +++ b/app/utils/compare-scatter-chart.ts @@ -0,0 +1,121 @@ +import type { VueUiScatterDatasetItem } from 'vue-data-ui' +import { applyEllipsis } from '~/utils/charts' +import { getFrameworkColor, isListedFramework } from '~/utils/frameworks' + +const MILLISECONDS_IN_A_DAY = 1000 * 60 * 60 * 24 + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +function toFreshnessScore(value: unknown, maximumAgeInDays = 365): number | null { + if (!value) { + return null + } + + const date = value instanceof Date ? value : new Date(String(value)) + + if (Number.isNaN(date.getTime())) { + return null + } + + const ageInMilliseconds = Date.now() - date.getTime() + const ageInDays = ageInMilliseconds / MILLISECONDS_IN_A_DAY + const normalizedFreshness = 1 - ageInDays / maximumAgeInDays + + if (normalizedFreshness < 0) { + return 0 + } + + if (normalizedFreshness > 1) { + return 1 + } + + return normalizedFreshness * 100 +} + +function getNumericFacetValue( + packageData: PackageComparisonData, + facet: ComparisonFacet, +): number | null { + switch (facet) { + case 'downloads': + return isFiniteNumber(packageData.downloads) ? packageData.downloads : null + + case 'totalLikes': + return isFiniteNumber(packageData.totalLikes) ? packageData.totalLikes : null + + case 'packageSize': + return isFiniteNumber(packageData.packageSize) ? packageData.packageSize : null + + case 'installSize': + return isFiniteNumber(packageData.installSize?.totalSize) + ? packageData.installSize.totalSize + : null + + case 'dependencies': + return isFiniteNumber(packageData.directDeps) ? packageData.directDeps : null + + case 'totalDependencies': + return isFiniteNumber(packageData.installSize?.dependencyCount) + ? packageData.installSize.dependencyCount + : null + + case 'vulnerabilities': + return isFiniteNumber(packageData.vulnerabilities?.count) + ? packageData.vulnerabilities.count + : null + + case 'types': + return packageData.analysis?.types?.kind ? 1 : 0 + + case 'lastUpdated': + return toFreshnessScore(packageData.metadata?.lastUpdated) + + default: + return null + } +} + +function getPackageName(packageData: PackageComparisonData, fallbackName: string): string { + return packageData.package?.name || fallbackName +} + +export function buildCompareScatterChartDataset( + packagesData: ReadonlyArray, + packages: string[], + xFacet: ComparisonFacet, + yFacet: ComparisonFacet, +): VueUiScatterDatasetItem[] { + return packagesData.reduce((acc, packageData, index) => { + if (!packageData) { + return acc + } + + const x = getNumericFacetValue(packageData, xFacet) + const y = getNumericFacetValue(packageData, yFacet) + + if (x === null || y === null) { + return acc + } + + const fallbackName = packages[index] || `package-${index + 1}` + const packageName = getPackageName(packageData, fallbackName) + + acc.push({ + name: applyEllipsis(packageName, 14), + fullName: packageName, + color: isListedFramework(packageName) ? getFrameworkColor(packageName) : undefined, + values: [ + { + x, + y, + name: applyEllipsis(packageName, 14), + fullName: packageName, + }, + ], + }) + + return acc + }, []) +} diff --git a/nuxt.config.ts b/nuxt.config.ts index b80700e7f5..8f058b5f7f 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -392,7 +392,7 @@ 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-scatter', 'vue-data-ui/vue-ui-horizontal-bar', 'virtua/vue', 'semver', diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index b028e55564..7a3dd1373c 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -79,11 +79,6 @@ const allowedWarnings: RegExp[] = [ /expose\(\) should be called only once/, ] -// Filter specific violations for rare edge cases (typically complex custom interactions in charts) -function filterViolations(results: AxeResults, ignoredRuleIds: string[]): AxeResults['violations'] { - return results.violations.filter(violation => !ignoredRuleIds.includes(violation.id)) -} - beforeEach(() => { warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) }) @@ -272,7 +267,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 FacetScatterChart from '~/components/Compare/FacetScatterChart.vue' import PackageLikeCard from '~/components/Package/LikeCard.vue' import SizeIncrease from '~/components/Package/SizeIncrease.vue' import Likes from '~/components/Package/Likes.vue' @@ -1022,9 +1017,9 @@ describe('component accessibility audits', () => { }) }) - describe('FacetQuadrantChart', () => { + describe('FacetScatterChart', () => { it('should have no accessibility violations', async () => { - const wrapper = await mountSuspended(FacetQuadrantChart, { + const wrapper = await mountSuspended(FacetScatterChart, { props: { packagesData: [ { @@ -1116,22 +1111,19 @@ describe('component accessibility audits', () => { }, }) const results = await runAxe(wrapper) - - const violations = filterViolations(results, ['nested-interactive', 'button-name']) - expect(violations).toEqual([]) + expect(results.violations).toEqual([]) }) it('should have no accessibility violations with empty data', async () => { - const wrapper = await mountSuspended(FacetQuadrantChart, { + const wrapper = await mountSuspended(FacetScatterChart, { props: { packagesData: [], packages: [], }, }) - const results = await runAxe(wrapper) - const violations = filterViolations(results, ['nested-interactive', 'button-name']) - expect(violations).toEqual([]) + const results = await runAxe(wrapper) + expect(results.violations).toEqual([]) }) }) diff --git a/test/unit/app/utils/compare-scatter-chart.spec.ts b/test/unit/app/utils/compare-scatter-chart.spec.ts new file mode 100644 index 0000000000..fe17339a18 --- /dev/null +++ b/test/unit/app/utils/compare-scatter-chart.spec.ts @@ -0,0 +1,1058 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { buildCompareScatterChartDataset } from '~/utils/compare-scatter-chart' + +vi.mock('~/utils/charts', () => ({ + applyEllipsis: vi.fn((value: string, limit: number) => `ellipsis(${value},${limit})`), +})) + +vi.mock('~/utils/frameworks', () => ({ + isListedFramework: vi.fn((name: string) => name === 'vue'), + getFrameworkColor: vi.fn((name: string) => `color:${name}`), +})) + +describe('buildCompareScatterChartDataset', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-11T12:00:00.000Z')) + }) + + afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() + }) + + it('builds a dataset entry with raw values, full name, ellipsed name, and framework color', () => { + const packagesData = [ + { + package: { name: 'vue' }, + downloads: 1200, + installSize: { totalSize: 4096 }, + }, + ] as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['fallback-package'], + 'downloads', + 'installSize', + ) + + expect(result).toEqual([ + { + name: 'ellipsis(vue,14)', + fullName: 'vue', + color: 'color:vue', + values: [ + { + x: 1200, + y: 4096, + name: 'ellipsis(vue,14)', + fullName: 'vue', + }, + ], + }, + ]) + }) + + it('does not set a color when the package is not a listed framework', () => { + const packagesData = [ + { + package: { name: 'some-library' }, + downloads: 100, + installSize: { totalSize: 200 }, + }, + ] as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['fallback-package'], + 'downloads', + 'installSize', + ) + + expect(result).toEqual([ + { + name: 'ellipsis(some-library,14)', + fullName: 'some-library', + color: undefined, + values: [ + { + x: 100, + y: 200, + name: 'ellipsis(some-library,14)', + fullName: 'some-library', + }, + ], + }, + ]) + }) + + it('uses the fallback package name when package.name is missing', () => { + const packagesData = [ + { + downloads: 50, + installSize: { totalSize: 75 }, + }, + ] as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['fallback-name'], + 'downloads', + 'installSize', + ) + + expect(result).toEqual([ + { + name: 'ellipsis(fallback-name,14)', + fullName: 'fallback-name', + color: undefined, + values: [ + { + x: 50, + y: 75, + name: 'ellipsis(fallback-name,14)', + fullName: 'fallback-name', + }, + ], + }, + ]) + }) + + it('uses an autogenerated fallback package name when packages[index] is missing', () => { + const packagesData = [ + { + downloads: 10, + installSize: { totalSize: 20 }, + }, + ] as PackageComparisonData[] + + const result = buildCompareScatterChartDataset(packagesData, [], 'downloads', 'installSize') + + expect(result).toEqual([ + { + name: 'ellipsis(package-1,14)', + fullName: 'package-1', + color: undefined, + values: [ + { + x: 10, + y: 20, + name: 'ellipsis(package-1,14)', + fullName: 'package-1', + }, + ], + }, + ]) + }) + + it('preserves the package index when determining fallback names', () => { + const packagesData = [ + null, + { + downloads: 11, + installSize: { totalSize: 22 }, + }, + ] as ReadonlyArray + + const result = buildCompareScatterChartDataset( + packagesData, + ['unused-first'], + 'downloads', + 'installSize', + ) + + expect(result).toEqual([ + { + name: 'ellipsis(package-2,14)', + fullName: 'package-2', + color: undefined, + values: [ + { + x: 11, + y: 22, + name: 'ellipsis(package-2,14)', + fullName: 'package-2', + }, + ], + }, + ]) + }) + + it('uses the fallback name when package.name is an empty string', () => { + const packagesData = [ + { + package: { name: '' }, + downloads: 12, + installSize: { totalSize: 34 }, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['fallback-empty-name'], + 'downloads', + 'installSize', + ) + + expect(result).toEqual([ + { + name: 'ellipsis(fallback-empty-name,14)', + fullName: 'fallback-empty-name', + color: undefined, + values: [ + { + x: 12, + y: 34, + name: 'ellipsis(fallback-empty-name,14)', + fullName: 'fallback-empty-name', + }, + ], + }, + ]) + }) + + it('skips null package entries', () => { + const packagesData = [ + null, + { + package: { name: 'vue' }, + downloads: 200, + installSize: { totalSize: 300 }, + }, + ] as ReadonlyArray + + const result = buildCompareScatterChartDataset( + packagesData, + ['first', 'second'], + 'downloads', + 'installSize', + ) + + expect(result).toEqual([ + { + name: 'ellipsis(vue,14)', + fullName: 'vue', + color: 'color:vue', + values: [ + { + x: 200, + y: 300, + name: 'ellipsis(vue,14)', + fullName: 'vue', + }, + ], + }, + ]) + }) + + it('skips entries when the x facet resolves to null', () => { + const packagesData = [ + { + package: { name: 'pkg-a' }, + downloads: 'invalid', + installSize: { totalSize: 300 }, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['pkg-a'], + 'downloads', + 'installSize', + ) + + expect(result).toEqual([]) + }) + + it('skips entries when the y facet resolves to null', () => { + const packagesData = [ + { + package: { name: 'pkg-a' }, + downloads: 300, + installSize: { totalSize: undefined }, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['pkg-a'], + 'downloads', + 'installSize', + ) + + expect(result).toEqual([]) + }) + + it('skips entries when a numeric facet is Infinity', () => { + const packagesData = [ + { + package: { name: 'infinite-downloads-package' }, + downloads: Infinity, + installSize: { totalSize: 200 }, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['infinite-downloads-package'], + 'downloads', + 'installSize', + ) + + expect(result).toEqual([]) + }) + + it('maps the types facet to 1 when a type kind exists and to 0 otherwise', () => { + const packagesData = [ + { + package: { name: 'typed-package' }, + analysis: { types: { kind: 'included' } }, + downloads: 120, + }, + { + package: { name: 'untyped-package' }, + analysis: { types: { kind: '' } }, + downloads: 140, + }, + ] as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['typed-package', 'untyped-package'], + 'types', + 'downloads', + ) + + expect(result).toEqual([ + { + name: 'ellipsis(typed-package,14)', + fullName: 'typed-package', + color: undefined, + values: [ + { + x: 1, + y: 120, + name: 'ellipsis(typed-package,14)', + fullName: 'typed-package', + }, + ], + }, + { + name: 'ellipsis(untyped-package,14)', + fullName: 'untyped-package', + color: undefined, + values: [ + { + x: 0, + y: 140, + name: 'ellipsis(untyped-package,14)', + fullName: 'untyped-package', + }, + ], + }, + ]) + }) + + it('returns 0 for the types facet when analysis.types is missing', () => { + const packagesData = [ + { + package: { name: 'pkg-no-types' }, + downloads: 77, + analysis: {}, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['pkg-no-types'], + 'types', + 'downloads', + ) + + expect(result).toEqual([ + { + name: 'ellipsis(pkg-no-types,14)', + fullName: 'pkg-no-types', + color: undefined, + values: [ + { + x: 0, + y: 77, + name: 'ellipsis(pkg-no-types,14)', + fullName: 'pkg-no-types', + }, + ], + }, + ]) + }) + + it('maps nested numeric facets correctly', () => { + const packagesData = [ + { + package: { name: 'pkg-nested' }, + directDeps: 12, + installSize: { + dependencyCount: 34, + totalSize: 1024, + }, + vulnerabilities: { + count: 2, + }, + }, + ] as PackageComparisonData[] + + expect( + buildCompareScatterChartDataset( + packagesData, + ['pkg-nested'], + 'dependencies', + 'totalDependencies', + ), + ).toEqual([ + { + name: 'ellipsis(pkg-nested,14)', + fullName: 'pkg-nested', + color: undefined, + values: [ + { + x: 12, + y: 34, + name: 'ellipsis(pkg-nested,14)', + fullName: 'pkg-nested', + }, + ], + }, + ]) + + expect( + buildCompareScatterChartDataset( + packagesData, + ['pkg-nested'], + 'vulnerabilities', + 'packageSize', + ), + ).toEqual([]) + }) + + it('maps totalLikes and packageSize facets correctly', () => { + const packagesData = [ + { + package: { name: 'metrics-package' }, + totalLikes: 42, + packageSize: 2048, + }, + ] as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['metrics-package'], + 'totalLikes', + 'packageSize', + ) + + expect(result).toEqual([ + { + name: 'ellipsis(metrics-package,14)', + fullName: 'metrics-package', + color: undefined, + values: [ + { + x: 42, + y: 2048, + name: 'ellipsis(metrics-package,14)', + fullName: 'metrics-package', + }, + ], + }, + ]) + }) + + it('maps vulnerabilities and packageSize facets correctly when both are present', () => { + const packagesData = [ + { + package: { name: 'security-package' }, + packageSize: 512, + vulnerabilities: { + count: 3, + }, + }, + ] as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['security-package'], + 'vulnerabilities', + 'packageSize', + ) + + expect(result).toEqual([ + { + name: 'ellipsis(security-package,14)', + fullName: 'security-package', + color: undefined, + values: [ + { + x: 3, + y: 512, + name: 'ellipsis(security-package,14)', + fullName: 'security-package', + }, + ], + }, + ]) + }) + + it('returns 100 freshness for a package updated just now', () => { + const now = new Date('2026-04-11T12:00:00.000Z') + + const packagesData = [ + { + package: { name: 'fresh-package' }, + metadata: { lastUpdated: now.toISOString() }, + downloads: 1000, + }, + ] as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['fresh-package'], + 'lastUpdated', + 'downloads', + ) + + expect(result).toEqual([ + { + name: 'ellipsis(fresh-package,14)', + fullName: 'fresh-package', + color: undefined, + values: [ + { + x: 100, + y: 1000, + name: 'ellipsis(fresh-package,14)', + fullName: 'fresh-package', + }, + ], + }, + ]) + }) + + it('returns 0 freshness for a package older than the maximum age window', () => { + const olderThanOneYear = new Date('2025-04-10T12:00:00.000Z') + + const packagesData = [ + { + package: { name: 'stale-package' }, + metadata: { lastUpdated: olderThanOneYear.toISOString() }, + downloads: 1000, + }, + ] as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['stale-package'], + 'lastUpdated', + 'downloads', + ) + + expect(result).toEqual([ + { + name: 'ellipsis(stale-package,14)', + fullName: 'stale-package', + color: undefined, + values: [ + { + x: 0, + y: 1000, + name: 'ellipsis(stale-package,14)', + fullName: 'stale-package', + }, + ], + }, + ]) + }) + + it('returns a proportional freshness score for partially old dates', () => { + const aboutHalfFresh = new Date('2025-10-11T12:00:00.000Z') + + const packagesData = [ + { + package: { name: 'mid-package' }, + metadata: { lastUpdated: aboutHalfFresh.toISOString() }, + downloads: 321, + }, + ] as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['mid-package'], + 'lastUpdated', + 'downloads', + ) + + expect(result).toHaveLength(1) + expect(result[0]?.values[0]?.x).toBeCloseTo(50.136986301369866) + expect(result[0]?.values[0]?.y).toBe(321) + }) + + it('skips entries when lastUpdated is invalid', () => { + const packagesData = [ + { + package: { name: 'broken-date-package' }, + metadata: { lastUpdated: 'not-a-date' }, + downloads: 100, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['broken-date-package'], + 'lastUpdated', + 'downloads', + ) + + expect(result).toEqual([]) + }) + + it('skips entries when lastUpdated is missing', () => { + const packagesData = [ + { + package: { name: 'missing-date-package' }, + metadata: {}, + downloads: 100, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['missing-date-package'], + 'lastUpdated', + 'downloads', + ) + + expect(result).toEqual([]) + }) + + it('clamps freshness to 100 for future dates', () => { + const futureDate = new Date('2026-04-12T12:00:00.000Z') + + const packagesData = [ + { + package: { name: 'future-package' }, + metadata: { lastUpdated: futureDate.toISOString() }, + downloads: 100, + }, + ] as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['future-package'], + 'lastUpdated', + 'downloads', + ) + + expect(result).toEqual([ + { + name: 'ellipsis(future-package,14)', + fullName: 'future-package', + color: undefined, + values: [ + { + x: 1, + y: 100, + name: 'ellipsis(future-package,14)', + fullName: 'future-package', + }, + ], + }, + ]) + }) + + it('skips entries when an unsupported facet is provided', () => { + const packagesData = [ + { + package: { name: 'unknown-facet-package' }, + downloads: 10, + installSize: { totalSize: 20 }, + }, + ] as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['unknown-facet-package'], + 'unknownFacet' as ComparisonFacet, + 'installSize', + ) + + expect(result).toEqual([]) + }) + + it('skips entries when installSize is missing', () => { + const packagesData = [ + { + package: { name: 'missing-install-size-package' }, + downloads: 100, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['missing-install-size-package'], + 'downloads', + 'installSize', + ) + + expect(result).toEqual([]) + }) + + it('supports Date instances for lastUpdated', () => { + const packagesData = [ + { + package: { name: 'date-instance-package' }, + metadata: { lastUpdated: new Date('2026-04-11T12:00:00.000Z') }, + downloads: 250, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['date-instance-package'], + 'lastUpdated', + 'downloads', + ) + + expect(result).toEqual([ + { + name: 'ellipsis(date-instance-package,14)', + fullName: 'date-instance-package', + color: undefined, + values: [ + { + x: 100, + y: 250, + name: 'ellipsis(date-instance-package,14)', + fullName: 'date-instance-package', + }, + ], + }, + ]) + }) + + it('returns 0 for types when analysis is missing entirely', () => { + const packagesData = [ + { + package: { name: 'missing-analysis-package' }, + downloads: 88, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['missing-analysis-package'], + 'types', + 'downloads', + ) + + expect(result).toEqual([ + { + name: 'ellipsis(missing-analysis-package,14)', + fullName: 'missing-analysis-package', + color: undefined, + values: [ + { + x: 0, + y: 88, + name: 'ellipsis(missing-analysis-package,14)', + fullName: 'missing-analysis-package', + }, + ], + }, + ]) + }) + + it('skips entries when totalDependencies is missing', () => { + const packagesData = [ + { + package: { name: 'missing-total-dependencies-package' }, + downloads: 100, + installSize: { + totalSize: 400, + }, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['missing-total-dependencies-package'], + 'downloads', + 'totalDependencies', + ) + + expect(result).toEqual([]) + }) + + it('skips entries when vulnerabilities count is missing', () => { + const packagesData = [ + { + package: { name: 'missing-vulnerabilities-package' }, + packageSize: 512, + vulnerabilities: {}, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['missing-vulnerabilities-package'], + 'vulnerabilities', + 'packageSize', + ) + + expect(result).toEqual([]) + }) + + it('returns 0 for types when kind is undefined', () => { + const packagesData = [ + { + package: { name: 'undefined-kind-package' }, + downloads: 91, + analysis: { + types: {}, + }, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['undefined-kind-package'], + 'types', + 'downloads', + ) + + expect(result).toEqual([ + { + name: 'ellipsis(undefined-kind-package,14)', + fullName: 'undefined-kind-package', + color: undefined, + values: [ + { + x: 0, + y: 91, + name: 'ellipsis(undefined-kind-package,14)', + fullName: 'undefined-kind-package', + }, + ], + }, + ]) + }) + + it('skips entries when totalDependencies is Infinity', () => { + const packagesData = [ + { + package: { name: 'infinite-total-dependencies-package' }, + downloads: 100, + installSize: { + dependencyCount: Infinity, + }, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['infinite-total-dependencies-package'], + 'downloads', + 'totalDependencies', + ) + + expect(result).toEqual([]) + }) + + it('skips entries when vulnerabilities count is Infinity', () => { + const packagesData = [ + { + package: { name: 'infinite-vulnerabilities-package' }, + packageSize: 512, + vulnerabilities: { + count: Infinity, + }, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['infinite-vulnerabilities-package'], + 'vulnerabilities', + 'packageSize', + ) + + expect(result).toEqual([]) + }) + + it('skips entries when installSize is undefined for totalDependencies', () => { + const packagesData = [ + { + package: { name: 'undefined-install-size-package' }, + downloads: 100, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['undefined-install-size-package'], + 'downloads', + 'totalDependencies', + ) + + expect(result).toEqual([]) + }) + + it('skips entries when vulnerabilities is undefined', () => { + const packagesData = [ + { + package: { name: 'undefined-vulnerabilities-package' }, + packageSize: 512, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['undefined-vulnerabilities-package'], + 'vulnerabilities', + 'packageSize', + ) + + expect(result).toEqual([]) + }) + + it('skips entries when totalLikes is missing', () => { + const packagesData = [ + { + package: { name: 'missing-total-likes-package' }, + packageSize: 100, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['missing-total-likes-package'], + 'totalLikes', + 'packageSize', + ) + + expect(result).toEqual([]) + }) + + it('skips entries when totalLikes is Infinity', () => { + const packagesData = [ + { + package: { name: 'infinite-total-likes-package' }, + totalLikes: Infinity, + packageSize: 100, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['infinite-total-likes-package'], + 'totalLikes', + 'packageSize', + ) + + expect(result).toEqual([]) + }) + + it('skips entries when packageSize is missing', () => { + const packagesData = [ + { + package: { name: 'missing-package-size-package' }, + totalLikes: 42, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['missing-package-size-package'], + 'totalLikes', + 'packageSize', + ) + + expect(result).toEqual([]) + }) + + it('skips entries when packageSize is Infinity', () => { + const packagesData = [ + { + package: { name: 'infinite-package-size-package' }, + totalLikes: 42, + packageSize: Infinity, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['infinite-package-size-package'], + 'totalLikes', + 'packageSize', + ) + + expect(result).toEqual([]) + }) + + it('skips entries when installSize totalSize is Infinity', () => { + const packagesData = [ + { + package: { name: 'infinite-install-size-package' }, + downloads: 100, + installSize: { + totalSize: Infinity, + }, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['infinite-install-size-package'], + 'downloads', + 'installSize', + ) + + expect(result).toEqual([]) + }) + + it('skips entries when directDeps is missing', () => { + const packagesData = [ + { + package: { name: 'missing-direct-deps-package' }, + downloads: 100, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['missing-direct-deps-package'], + 'dependencies', + 'downloads', + ) + + expect(result).toEqual([]) + }) + + it('skips entries when directDeps is Infinity', () => { + const packagesData = [ + { + package: { name: 'infinite-direct-deps-package' }, + directDeps: Infinity, + downloads: 100, + }, + ] as unknown as PackageComparisonData[] + + const result = buildCompareScatterChartDataset( + packagesData, + ['infinite-direct-deps-package'], + 'dependencies', + 'downloads', + ) + + expect(result).toEqual([]) + }) +}) From 6bba1b8a44f426e61859bb2ed716cd4985a6a953 Mon Sep 17 00:00:00 2001 From: graphieros Date: Sat, 11 Apr 2026 10:44:12 +0200 Subject: [PATCH 04/19] fix: improve legend in compare downloads chart --- app/components/Package/TrendsChart.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index e81a32d804..69747c0dd8 100644 --- a/app/components/Package/TrendsChart.vue +++ b/app/components/Package/TrendsChart.vue @@ -1994,7 +1994,7 @@ const isSparklineLayout = computed({