Skip to content

feat: add quadrant chart to compare page#2388

Open
graphieros wants to merge 37 commits intomainfrom
compare-quadrant-chart
Open

feat: add quadrant chart to compare page#2388
graphieros wants to merge 37 commits intomainfrom
compare-quadrant-chart

Conversation

@graphieros
Copy link
Copy Markdown
Contributor

@graphieros graphieros commented Apr 5, 2026

Resolves #2387

This adds a quadrant chart to the compare page, to visualise comparisons along 2 axes:

  • X: adoption
  • Y: package efficiency

The chart is displayed below the bar charts, and includes:

  • png, svg exports
  • alt text copy feature
Dark mode Light mode
dark mode light mode
  • A tooltip explains how the data is processed:
image
  • Hovering datapoints reveals facets ordered through both axes of the quadrant:
image

Data processing:

  • log scaled to handle large disparities: downloads, likes, install size, dependencies, package size
  • adoption score: mostly driven by downloads, with small contributions from freshness and npmx likes
  • efficiency score: based on install size, dependencies, vulnerabilities, TS support, deprecation

Some signals are inverted (so lower is better): size, deps, vulnerabilities
Deprecation is a hard override and forces min efficiency

The weights I have chosen can be subject to discussion, because of their arbitrary nature:

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.2, // 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

Other

  • Bump vue-data-ui to 3.17.9 with updates for the quadrant chart component

@graphieros graphieros linked an issue Apr 5, 2026 that may be closed by this pull request
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Apr 6, 2026 0:54am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Apr 6, 2026 0:54am
npmx-lunaria Ignored Ignored Apr 6, 2026 0:54am

Request Review

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 5, 2026

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

File Note
i18n/locales/en.json Source changed, localizations will be marked as outdated.
i18n/locales/fr-FR.json Localization changed, will be marked as complete.
Warnings reference
Icon Description
🔄️ The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

@graphieros graphieros marked this pull request as draft April 5, 2026 08:48
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 5, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a quadrant comparison chart to the Compare page: a new Vue <script setup> component FacetQuadrantChart.vue that maps incoming package data to a normalized quadrant dataset via createQuadrantDataset, computes adoption/efficiency scores (including freshness, vulnerability penalty, types/deprecation handling), assigns quadrants, and renders with VueUiQuadrant. Implements themed styling, responsive tooltips, PNG/SVG export handlers, alt-text generation/copy, print watermark sizing options, i18n entries/schema updates (EN/FR), a vue-data-ui dependency bump, and accompanying unit and a11y tests.

Possibly related PRs

Suggested labels

front, a11y

Suggested reviewers

  • danielroe
  • alexdln
  • ghostdevv
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR contains one minor out-of-scope change: FacetBarChart.vue colour adjustment from fgSubtle to fg, unrelated to quadrant chart feature. Consider separating the FacetBarChart.vue styling change into a separate PR or clarify its relationship to the quadrant chart feature.
✅ Passed checks (2 passed)
Check name Status Explanation
Description check ✅ Passed The PR description clearly relates to the changeset, detailing a new quadrant chart feature for the compare page with adoption and efficiency axes.
Linked Issues check ✅ Passed The PR implements the quadrant chart feature resolving issue #2387, with all core requirements met including chart display, exports, alt-text, and scoring methodology.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch compare-quadrant-chart

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 5, 2026

Codecov Report

❌ Patch coverage is 64.28571% with 75 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
app/components/Compare/FacetQuadrantChart.vue 66.33% 28 Missing and 6 partials ⚠️
app/utils/charts.ts 0.00% 22 Missing and 5 partials ⚠️
app/composables/useChartWatermark.ts 0.00% 8 Missing and 1 partial ⚠️
app/utils/compare-quadrant-chart.ts 93.15% 2 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: be4b4196-422e-437b-89ff-c11f23cf7b05

📥 Commits

Reviewing files that changed from the base of the PR and between 5324b96 and 11fd407.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • app/components/Compare/FacetQuadrantChart.vue
  • app/composables/useChartWatermark.ts
  • app/pages/compare.vue
  • app/utils/charts.ts
  • app/utils/compare-quadrant-chart.ts
  • i18n/locales/en.json
  • i18n/locales/fr-FR.json
  • i18n/schema.json
  • package.json
  • test/nuxt/a11y.spec.ts
  • test/unit/app/utils/compare-quadrant-chart.spec.ts

@graphieros graphieros marked this pull request as draft April 5, 2026 10:05
@graphieros graphieros marked this pull request as ready for review April 5, 2026 14:40
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
app/utils/charts.ts (1)

708-714: 🛠️ Refactor suggestion | 🟠 Major

Use the proper config type instead of any.

copyAltTextForCompareQuadrantChart still uses any for the config type parameter, whilst its sibling createAltTextForCompareQuadrantChart correctly uses CompareQuadrantChartConfig. This is inconsistent and violates type-safety. As per coding guidelines: "Ensure you write strictly type-safe code".

Suggested fix
 export async function copyAltTextForCompareQuadrantChart({
   dataset,
   config,
-}: AltCopyArgs<VueUiQuadrantDatapoint[], any>) {
+}: AltCopyArgs<VueUiQuadrantDatapoint[], CompareQuadrantChartConfig>) {
   const altText = createAltTextForCompareQuadrantChart({ dataset, config })
   await config.copy(altText)
 }
🧹 Nitpick comments (2)
i18n/locales/en.json (1)

1127-1150: Terminology mismatch between quadrant labels and alt-text descriptions.

The quadrant labels use different terminology than the alt-text side analysis:

  • label_top_left: "promising" vs side_analysis_top_left: "efficient"
  • label_bottom_right: "popular but heavy" vs side_analysis_bottom_right: "resource-heavy"

Consider aligning these for consistency so that users encountering the alt-text receive the same terminology as shown on the chart.

Suggested alignment
-        "side_analysis_top_left": "The following packages are positioned on the top-left quadrant (efficient): {packages}",
+        "side_analysis_top_left": "The following packages are positioned on the top-left quadrant (promising): {packages}",
-        "side_analysis_bottom_right": "The following packages are positioned on the bottom-right quadrant (resource-heavy): {packages}",
+        "side_analysis_bottom_right": "The following packages are positioned on the bottom-right quadrant (popular but heavy): {packages}",
app/components/Compare/FacetQuadrantChart.vue (1)

107-125: Use proper type instead of any and consider avoiding spread in map.

Line 109 uses any type for el, but rawQuadrant.value returns PackageQuadrantPoint[]. Using the proper type improves type safety. Additionally, the static analysis tool flagged the spread operator in map as inefficient.

Suggested fix
+import type { PackageQuadrantPoint } from '~/utils/compare-quadrant-chart'

 const dataset = computed<VueUiQuadrantDatasetItem[]>(() => {
-  return rawQuadrant.value.map((el: any) => {
-    return {
-      ...el,
+  return rawQuadrant.value.map((el: PackageQuadrantPoint): VueUiQuadrantDatasetItem => {
+    const ellipsizedName = applyEllipsis(el.name, 20)
+    return {
+      adoptionScore: el.adoptionScore,
+      efficiencyScore: el.efficiencyScore,
+      id: el.id,
+      license: el.license,
+      name: ellipsizedName,
+      metrics: el.metrics,
+      quadrant: el.quadrant,
+      x: el.x,
+      y: el.y,
       fullname: el.name,
-      name: applyEllipsis(el.name, 20),
       shape: 'circle',
       color: isListedFramework(el.name) ? getFrameworkColor(el.name) : undefined,
       series: [
         {
-          name: applyEllipsis(el.name, 20),
+          name: ellipsizedName,
           x: el.x,
           y: el.y,
         },
       ],
     }
   })
 })

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2853caa5-78c1-4501-b5f9-45d323ec9044

📥 Commits

Reviewing files that changed from the base of the PR and between 11fd407 and 901f8c2.

📒 Files selected for processing (8)
  • app/components/Compare/FacetBarChart.vue
  • app/components/Compare/FacetQuadrantChart.vue
  • app/utils/charts.ts
  • app/utils/compare-quadrant-chart.ts
  • i18n/locales/en.json
  • i18n/locales/fr-FR.json
  • i18n/schema.json
  • test/unit/app/utils/compare-quadrant-chart.spec.ts
✅ Files skipped from review due to trivial changes (1)
  • test/unit/app/utils/compare-quadrant-chart.spec.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • i18n/locales/fr-FR.json
  • i18n/schema.json

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
app/components/Compare/FacetQuadrantChart.vue (1)

136-183: Extract the tooltip renderer out of config.

This computed now mixes chart options, export wiring, and a large HTML template string. Pulling the tooltip formatter into a helper (or small dedicated component) would keep the config declarative and make the metric layout much easier to test.

As per coding guidelines "Keep functions focused and manageable (generally under 50 lines)".

Also applies to: 249-320


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d4c1378e-e4d8-49e2-9f38-20701313fbe0

📥 Commits

Reviewing files that changed from the base of the PR and between 901f8c2 and c27eb19.

📒 Files selected for processing (4)
  • app/components/Compare/FacetQuadrantChart.vue
  • app/utils/compare-quadrant-chart.ts
  • i18n/locales/en.json
  • i18n/locales/fr-FR.json
✅ Files skipped from review due to trivial changes (2)
  • i18n/locales/fr-FR.json
  • app/utils/compare-quadrant-chart.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • i18n/locales/en.json

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
app/components/Compare/FacetQuadrantChart.vue (2)

113-128: Consider avoiding object spread in map for slight efficiency gain.

Static analysis flags the spread operator in .map() as inefficient since it creates intermediate objects. For the typical small number of packages being compared, the impact is negligible, but if you wish to address the lint warning:

♻️ Suggested refactor to avoid spread
 const dataset = computed<VueUiQuadrantDatasetItem[]>(() => {
   return rawQuadrant.value.map((el: PackageQuadrantPoint) => {
-    return {
-      ...el,
-      fullname: el.name,
-      name: applyEllipsis(el.name, 20),
-      shape: 'circle',
-      color: isListedFramework(el.name) ? getFrameworkColor(el.name) : undefined,
-      series: [
-        {
-          name: applyEllipsis(el.name, 20),
-          x: el.x,
-          y: el.y,
-        },
-      ],
-    }
+    const item: VueUiQuadrantDatasetItem = {
+      id: el.id,
+      license: el.license,
+      name: applyEllipsis(el.name, 20),
+      fullname: el.name,
+      x: el.x,
+      y: el.y,
+      adoptionScore: el.adoptionScore,
+      efficiencyScore: el.efficiencyScore,
+      quadrant: el.quadrant,
+      metrics: el.metrics,
+      shape: 'circle',
+      color: isListedFramework(el.name) ? getFrameworkColor(el.name) : undefined,
+      series: [
+        {
+          name: applyEllipsis(el.name, 20),
+          x: el.x,
+          y: el.y,
+        },
+      ],
+    }
+    return item
   })
 })

287-316: Add fallback values for consistency and robustness.

Some metrics use ?? 0 fallbacks (lines 281, 285) while others don't. If datapoint?.category?.metrics is unexpectedly undefined, this could display "NaN%" or "undefined" in the tooltip.

♻️ Suggested fix to add consistent fallbacks
                 <div class="flex flex-row items-baseline gap-2">
                   <span class="text-fg-subtle">${$t('compare.quadrant_chart.label_freshness_score')}</span>
-                  <span class="text-fg text-sm">${Math.round(datapoint?.category?.metrics.freshnessPercent)}%</span>
+                  <span class="text-fg text-sm">${Math.round(datapoint?.category?.metrics?.freshnessPercent ?? 0)}%</span>
                 </div>

                 <div class="text-fg text-xs mt-4">${$t('compare.quadrant_chart.label_y_axis')}</div>
                 <div class="flex flex-row items-baseline gap-2">
                   <span class="text-fg-subtle">${$t('compare.facets.items.installSize.label')}</span>
-                  <span class="text-fg text-sm">${bytesFormatter.format(datapoint?.category?.metrics.installSize)}</span>
+                  <span class="text-fg text-sm">${bytesFormatter.format(datapoint?.category?.metrics?.installSize ?? 0)}</span>
                 </div>
                 <div class="flex flex-row items-baseline gap-2">
                   <span class="text-fg-subtle">${$t('compare.facets.items.packageSize.label')}</span>
-                  <span class="text-fg text-sm">${bytesFormatter.format(datapoint?.category?.metrics.packageSize)}</span>
+                  <span class="text-fg text-sm">${bytesFormatter.format(datapoint?.category?.metrics?.packageSize ?? 0)}</span>
                 </div>
                 <div class="flex flex-row items-baseline gap-2">
                   <span class="text-fg-subtle">${$t('compare.facets.items.dependencies.label')}</span>
-                  <span class="text-fg text-sm">${datapoint?.category?.metrics.dependencies}</span>
+                  <span class="text-fg text-sm">${datapoint?.category?.metrics?.dependencies ?? 0}</span>
                 </div>
                 <div class="flex flex-row items-baseline gap-2">
                   <span class="text-fg-subtle">${$t('compare.facets.items.totalDependencies.label')}</span>
-                  <span class="text-fg text-sm">${datapoint?.category?.metrics.totalDependencies}</span>
+                  <span class="text-fg text-sm">${datapoint?.category?.metrics?.totalDependencies ?? 0}</span>
                 </div>
                 <div class="flex flex-row items-baseline gap-2">
                   <span class="text-fg-subtle">${$t('compare.facets.items.vulnerabilities.label')}</span>
-                  <span class="text-fg text-sm">${datapoint?.category?.metrics.vulnerabilities}</span>
+                  <span class="text-fg text-sm">${datapoint?.category?.metrics?.vulnerabilities ?? 0}</span>
                 </div>

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 043b74b5-3b22-4b95-acb5-71e2dc39cb09

📥 Commits

Reviewing files that changed from the base of the PR and between c27eb19 and 2150f0a.

📒 Files selected for processing (4)
  • app/components/Compare/FacetQuadrantChart.vue
  • i18n/locales/en.json
  • i18n/locales/fr-FR.json
  • i18n/schema.json
🚧 Files skipped from review as they are similar to previous changes (3)
  • i18n/locales/fr-FR.json
  • i18n/locales/en.json
  • i18n/schema.json

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
app/components/Compare/FacetQuadrantChart.vue (1)

113-128: Consider explicit property assignment to avoid spread in map.

The static analyser flags spreading inside .map() as inefficient. Since PackageQuadrantPoint has a known shape, you could destructure and reassign explicitly. However, if the utility may add properties in future that the chart relies on at runtime, keeping the spread preserves forward compatibility.

♻️ Optional: explicit property assignment
 const dataset = computed<VueUiQuadrantDatasetItem[]>(() => {
-  return rawQuadrant.value.map((el: PackageQuadrantPoint) => {
-    return {
-      ...el,
-      fullname: el.name,
-      name: applyEllipsis(el.name, 20),
-      shape: 'circle',
-      color: isListedFramework(el.name) ? getFrameworkColor(el.name) : undefined,
-      series: [
-        {
-          name: applyEllipsis(el.name, 20),
-          x: el.x,
-          y: el.y,
-        },
-      ],
-    }
+  return rawQuadrant.value.map((el: PackageQuadrantPoint) => ({
+    x: el.x,
+    y: el.y,
+    quadrant: el.quadrant,
+    category: el.category,
+    fullname: el.name,
+    name: applyEllipsis(el.name, 20),
+    shape: 'circle' as const,
+    color: isListedFramework(el.name) ? getFrameworkColor(el.name) : undefined,
+    series: [{ name: applyEllipsis(el.name, 20), x: el.x, y: el.y }],
+  }))
-  })
 })

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 35dff1bd-c07b-42e0-9c66-353be24e2794

📥 Commits

Reviewing files that changed from the base of the PR and between 2150f0a and 92e693f.

📒 Files selected for processing (1)
  • app/components/Compare/FacetQuadrantChart.vue


const rawQuadrant = computed(() => createQuadrantDataset(source.value))
const dataset = computed<VueUiQuadrantDatasetItem[]>(() => {
return rawQuadrant.value.map((el: PackageQuadrantPoint) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you fix the oxlint warning on this line

oxc(no-map-spread)
Spreading to modify object properties in `map` calls is inefficient

Comment on lines +220 to +221
if (!packages.length) return []
return packages.map(packageItem => createQuadrantPoint(packageItem))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't this if kinda redundant? we can keep it if you want, just checking!

} from '~/utils/charts'
import { drawNpmxLogoAndTaglineWatermark } from '~/composables/useChartWatermark'

import('vue-data-ui/style.css')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import('vue-data-ui/style.css')
import 'vue-data-ui/style.css';

would this also work?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import('vue-data-ui/style.css') is a dynamic css import (was refactored to this by Daniel on TrendsChart.vue last month)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

add quadrant chart on the compare page

3 participants