[READ-ONLY] a fast, modern browser for the npm registry

feat(ui): community version distribution (#1245)

authored by

Vincent Taverna and committed by
GitHub
4fd68673 bb234a14

+1540 -64
+6 -1
app/components/Package/ChartModal.vue
··· 1 1 <script setup lang="ts"> 2 - defineProps<{ 2 + const props = defineProps<{ 3 3 modalTitle?: string 4 + }>() 5 + 6 + const emit = defineEmits<{ 7 + (e: 'transitioned'): void 4 8 }>() 5 9 </script> 6 10 ··· 9 13 :modalTitle="modalTitle ?? $t('package.trends.title')" 10 14 id="chart-modal" 11 15 class="h-full sm:h-min sm:border sm:border-border sm:rounded-lg shadow-xl sm:max-h-[90vh] sm:max-w-3xl" 16 + @transitioned="emit('transitioned')" 12 17 > 13 18 <div class="font-mono text-sm"> 14 19 <slot />
+8 -26
app/components/Package/TrendsChart.vue
··· 5 5 import { useCssVariables } from '~/composables/useColors' 6 6 import { OKLCH_NEUTRAL_FALLBACK, transparentizeOklch } from '~/utils/colors' 7 7 import { getFrameworkColor, isListedFramework } from '~/utils/frameworks' 8 + import { drawNpmxLogoAndTaglineWatermark } from '~/composables/useChartWatermark' 8 9 9 10 const props = defineProps<{ 10 11 // For single package downloads history ··· 99 100 ? (accentColorValueById.value[id] ?? colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK) 100 101 : (colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK) 101 102 }) 103 + 104 + const watermarkColors = computed(() => ({ 105 + fg: colors.value.fg ?? OKLCH_NEUTRAL_FALLBACK, 106 + bg: colors.value.bg ?? OKLCH_NEUTRAL_FALLBACK, 107 + fgSubtle: colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK, 108 + })) 102 109 103 110 const mobileBreakpointWidth = 640 104 111 const isMobile = computed(() => width.value > 0 && width.value < mobileBreakpointWidth) ··· 1241 1248 return seriesNames.join('\n') 1242 1249 } 1243 1250 1244 - /** 1245 - * Build and return npmx svg logo and tagline, to be injected during PNG & SVG exports 1246 - */ 1247 - function drawNpmxLogoAndTaglineWatermark(svg: Record<string, any>) { 1248 - if (!svg?.drawingArea) return '' 1249 - const npmxLogoWidthToHeight = 2.64 1250 - const npmxLogoWidth = 100 1251 - const npmxLogoHeight = npmxLogoWidth / npmxLogoWidthToHeight 1252 - 1253 - return ` 1254 - <svg x="${svg.drawingArea.left + svg.drawingArea.width / 2 - npmxLogoWidth / 2 - 3}" y="${svg.height - npmxLogoHeight}" width="${npmxLogoWidth}" height="${npmxLogoHeight}" viewBox="0 0 330 125" fill="none" xmlns="http://www.w3.org/2000/svg"> 1255 - <path d="M22.848 97V85.288H34.752V97H22.848ZM56.4105 107.56L85.5945 25H93.2745L64.0905 107.56H56.4105ZM121.269 97V46.12H128.661L128.949 59.08L127.989 58.216C128.629 55.208 129.781 52.744 131.445 50.824C133.173 48.84 135.221 47.368 137.589 46.408C139.957 45.448 142.453 44.968 145.077 44.968C148.981 44.968 152.213 45.832 154.773 47.56C157.397 49.288 159.381 51.624 160.725 54.568C162.069 57.448 162.741 60.68 162.741 64.264V97H154.677V66.568C154.677 61.832 153.749 58.248 151.893 55.816C150.037 53.32 147.189 52.072 143.349 52.072C140.725 52.072 138.357 52.648 136.245 53.8C134.133 54.888 132.437 56.52 131.157 58.696C129.941 60.808 129.333 63.432 129.333 66.568V97H121.269ZM173.647 111.4V46.12H181.135L181.327 57.64L180.175 57.064C181.455 53.096 183.568 50.088 186.512 48.04C189.519 45.992 192.976 44.968 196.88 44.968C201.936 44.968 206.064 46.216 209.264 48.712C212.528 51.208 214.928 54.472 216.464 58.504C218 62.536 218.767 66.888 218.767 71.56C218.767 76.232 218 80.584 216.464 84.616C214.928 88.648 212.528 91.912 209.264 94.408C206.064 96.904 201.936 98.152 196.88 98.152C194.256 98.152 191.792 97.704 189.487 96.808C187.247 95.912 185.327 94.664 183.727 93.064C182.191 91.464 181.135 89.576 180.559 87.4L181.711 86.056V111.4H173.647ZM196.111 90.472C200.528 90.472 203.984 88.808 206.48 85.48C209.04 82.152 210.319 77.512 210.319 71.56C210.319 65.608 209.04 60.968 206.48 57.64C203.984 54.312 200.528 52.648 196.111 52.648C193.167 52.648 190.607 53.352 188.431 54.76C186.319 56.168 184.655 58.28 183.439 61.096C182.287 63.912 181.711 67.4 181.711 71.56C181.711 75.72 182.287 79.208 183.439 82.024C184.591 84.84 186.255 86.952 188.431 88.36C190.607 89.768 193.167 90.472 196.111 90.472ZM222.57 97V46.12H229.962L230.25 57.448L229.29 57.256C229.866 53.48 231.082 50.504 232.938 48.328C234.858 46.088 237.29 44.968 240.234 44.968C243.242 44.968 245.546 46.056 247.146 48.232C248.81 50.408 249.834 53.608 250.218 57.832H249.258C249.834 53.864 251.114 50.728 253.098 48.424C255.146 46.12 257.706 44.968 260.778 44.968C264.874 44.968 267.85 46.376 269.706 49.192C271.562 52.008 272.49 56.68 272.49 63.208V97H264.426V64.36C264.426 59.816 263.946 56.648 262.986 54.856C262.026 53 260.522 52.072 258.474 52.072C257.13 52.072 255.946 52.52 254.922 53.416C253.898 54.248 253.066 55.592 252.426 57.448C251.85 59.304 251.562 61.672 251.562 64.552V97H243.498V64.36C243.498 60.008 243.018 56.872 242.058 54.952C241.162 53.032 239.658 52.072 237.546 52.072C236.202 52.072 235.018 52.52 233.994 53.416C232.97 54.248 232.138 55.592 231.498 57.448C230.922 59.304 230.634 61.672 230.634 64.552V97H222.57ZM276.676 97L295.396 70.888L277.636 46.12H287.044L300.388 65.32L313.444 46.12H323.044L305.38 71.08L323.908 97H314.5L300.388 76.456L286.276 97H276.676Z" fill="${colors.value.fg}"/> 1256 - </svg> 1257 - <text 1258 - fill="${colors.value.fgMuted}" 1259 - x="${svg.drawingArea.left + svg.drawingArea.width / 2}" 1260 - y="${svg.height - npmxLogoHeight - 6}" 1261 - font-size="12" 1262 - text-anchor="middle" 1263 - > 1264 - ${$t('tagline')} 1265 - </text> 1266 - ` 1267 - } 1268 - 1269 1251 // VueUiXy chart component configuration 1270 1252 const chartConfig = computed(() => { 1271 1253 return { ··· 1563 1545 <!-- Inject npmx logo & tagline during SVG and PNG print --> 1564 1546 <g 1565 1547 v-if="svg.isPrintingSvg || svg.isPrintingImg" 1566 - v-html="drawNpmxLogoAndTaglineWatermark(svg)" 1548 + v-html="drawNpmxLogoAndTaglineWatermark(svg, watermarkColors, $t, 'bottom')" 1567 1549 /> 1568 1550 1569 1551 <!-- Overlay covering the chart area to hide line resizing when switching granularities recalculates VueUiXy scaleMax when estimation lines are necessary -->
+618
app/components/Package/VersionDistribution.vue
··· 1 + <script setup lang="ts"> 2 + import { VueUiXy } from 'vue-data-ui/vue-ui-xy' 3 + import type { 4 + VueUiXyDatasetItem, 5 + VueUiXyDatasetBarItem, 6 + VueUiXyDatapointItem, 7 + MinimalCustomFormatParams, 8 + } from 'vue-data-ui' 9 + import { useElementSize } from '@vueuse/core' 10 + import { useCssVariables } from '~/composables/useColors' 11 + import { OKLCH_NEUTRAL_FALLBACK, transparentizeOklch } from '~/utils/colors' 12 + import { 13 + drawSvgPrintLegend, 14 + drawNpmxLogoAndTaglineWatermark, 15 + } from '~/composables/useChartWatermark' 16 + import TooltipApp from '~/components/Tooltip/App.vue' 17 + 18 + type TooltipParams = MinimalCustomFormatParams<VueUiXyDatapointItem[]> & { 19 + bars: VueUiXyDatasetBarItem[] 20 + } 21 + 22 + const props = defineProps<{ 23 + packageName: string 24 + inModal?: boolean 25 + }>() 26 + 27 + const { accentColors, selectedAccentColor } = useAccentColor() 28 + const colorMode = useColorMode() 29 + const resolvedMode = shallowRef<'light' | 'dark'>('light') 30 + const rootEl = shallowRef<HTMLElement | null>(null) 31 + 32 + onMounted(async () => { 33 + rootEl.value = document.documentElement 34 + resolvedMode.value = colorMode.value === 'dark' ? 'dark' : 'light' 35 + }) 36 + 37 + const { colors } = useCssVariables( 38 + ['--bg', '--fg', '--bg-subtle', '--bg-elevated', '--fg-subtle', '--border', '--border-subtle'], 39 + { 40 + element: rootEl, 41 + watchHtmlAttributes: true, 42 + watchResize: false, 43 + }, 44 + ) 45 + 46 + watch( 47 + () => colorMode.value, 48 + value => { 49 + resolvedMode.value = value === 'dark' ? 'dark' : 'light' 50 + }, 51 + { flush: 'sync' }, 52 + ) 53 + 54 + const isDarkMode = computed(() => resolvedMode.value === 'dark') 55 + 56 + const accentColorValueById = computed<Record<string, string>>(() => { 57 + const map: Record<string, string> = {} 58 + for (const item of accentColors.value) { 59 + map[item.id] = item.value 60 + } 61 + return map 62 + }) 63 + 64 + const accent = computed(() => { 65 + const id = selectedAccentColor.value 66 + return id 67 + ? (accentColorValueById.value[id] ?? colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK) 68 + : (colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK) 69 + }) 70 + 71 + const watermarkColors = computed(() => ({ 72 + fg: colors.value.fg ?? OKLCH_NEUTRAL_FALLBACK, 73 + bg: colors.value.bg ?? OKLCH_NEUTRAL_FALLBACK, 74 + fgSubtle: colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK, 75 + })) 76 + 77 + const { width } = useElementSize(rootEl) 78 + const mobileBreakpointWidth = 640 79 + const isMobile = computed(() => width.value > 0 && width.value < mobileBreakpointWidth) 80 + 81 + const { 82 + groupingMode, 83 + showRecentOnly, 84 + showLowUsageVersions, 85 + pending, 86 + error, 87 + chartDataset, 88 + hasData, 89 + } = useVersionDistribution(() => props.packageName) 90 + 91 + const compactNumberFormatter = useCompactNumberFormatter() 92 + 93 + // Show loading indicator immediately to maintain stable layout 94 + const showLoadingIndicator = computed(() => pending.value) 95 + 96 + const chartConfig = computed(() => { 97 + return { 98 + theme: isDarkMode.value ? 'dark' : 'default', 99 + chart: { 100 + height: isMobile.value ? 500 : 400, 101 + backgroundColor: colors.value.bg, 102 + padding: { 103 + top: 24, 104 + right: 24, 105 + bottom: xAxisLabels.value.length > 10 ? 100 : 88, // Space for rotated labels + watermark 106 + left: isMobile.value ? 60 : 80, 107 + }, 108 + userOptions: { 109 + buttons: { 110 + pdf: false, 111 + labels: false, 112 + fullscreen: false, 113 + table: false, 114 + tooltip: false, 115 + }, 116 + }, 117 + grid: { 118 + stroke: colors.value.border, 119 + labels: { 120 + fontSize: isMobile.value ? 24 : 16, 121 + color: pending.value ? colors.value.border : colors.value.fgSubtle, 122 + axis: { 123 + yLabel: 'Downloads', 124 + yLabelOffsetX: 12, 125 + fontSize: isMobile.value ? 32 : 24, 126 + }, 127 + yAxis: { 128 + formatter: ({ value }: { value: number }) => { 129 + return compactNumberFormatter.value.format(Number.isFinite(value) ? value : 0) 130 + }, 131 + useNiceScale: true, 132 + }, 133 + xAxisLabels: { 134 + show: xAxisLabels.value.length <= 25, 135 + values: xAxisLabels.value, 136 + fontSize: isMobile.value ? 16 : 14, 137 + color: colors.value.fgSubtle, 138 + rotation: xAxisLabels.value.length > 10 ? 45 : 0, 139 + }, 140 + }, 141 + }, 142 + highlighter: { useLine: false }, 143 + legend: { show: false, position: 'top' }, 144 + bar: { 145 + periodGap: 16, 146 + innerGap: 8, 147 + borderRadius: 4, 148 + }, 149 + tooltip: { 150 + teleportTo: props.inModal ? '#chart-modal' : undefined, 151 + borderColor: 'transparent', 152 + backdropFilter: false, 153 + backgroundColor: 'transparent', 154 + customFormat: (params: TooltipParams) => { 155 + const { datapoint, absoluteIndex, bars } = params 156 + if (!datapoint) return '' 157 + 158 + // Use absoluteIndex to get the correct version from chartDataset 159 + const index = Number(absoluteIndex) 160 + if (!Number.isInteger(index) || index < 0 || index >= chartDataset.value.length) return '' 161 + const chartItem = chartDataset.value[index] 162 + 163 + if (!chartItem) return '' 164 + 165 + const barSeries = Array.isArray(bars?.[0]?.series) ? bars[0].series : [] 166 + const barValue = index < barSeries.length ? barSeries[index] : undefined 167 + const raw = Number(barValue ?? chartItem.downloads ?? 0) 168 + const v = compactNumberFormatter.value.format(Number.isFinite(raw) ? raw : 0) 169 + 170 + return `<div class="font-mono text-xs p-3 border border-border rounded-md bg-[var(--bg)]/10 backdrop-blur-md"> 171 + <div class="flex flex-col gap-2"> 172 + <div class="flex items-center justify-between gap-4"> 173 + <span class="text-3xs uppercase tracking-wide text-[var(--fg)]/70"> 174 + ${chartItem.name} 175 + </span> 176 + <span class="text-base text-[var(--fg)] font-mono tabular-nums"> 177 + ${v} 178 + </span> 179 + </div> 180 + </div> 181 + </div>` 182 + }, 183 + }, 184 + zoom: { 185 + maxWidth: isMobile.value ? 350 : 500, 186 + highlightColor: colors.value.bgElevated, 187 + minimap: { 188 + show: true, 189 + lineColor: '#FAFAFA', 190 + selectedColor: accent.value, 191 + selectedColorOpacity: 0.06, 192 + frameColor: colors.value.border, 193 + }, 194 + preview: { 195 + fill: transparentizeOklch(accent.value, isDarkMode.value ? 0.95 : 0.92), 196 + stroke: transparentizeOklch(accent.value, 0.5), 197 + strokeWidth: 1, 198 + strokeDasharray: 3, 199 + }, 200 + }, 201 + }, 202 + table: { 203 + show: false, 204 + }, 205 + } 206 + }) 207 + 208 + // VueUiXy expects one series with multiple values for bar charts 209 + const xyDataset = computed<VueUiXyDatasetItem[]>(() => { 210 + if (!chartDataset.value.length) return [] 211 + 212 + return [ 213 + { 214 + name: props.packageName, 215 + series: chartDataset.value.map(item => item.downloads), 216 + type: 'bar' as const, 217 + color: accent.value, 218 + }, 219 + ] 220 + }) 221 + 222 + const xAxisLabels = computed(() => { 223 + return chartDataset.value.map(item => item.name) 224 + }) 225 + 226 + // Handle keyboard navigation for semver group toggle 227 + function handleGroupingKeydown(event: KeyboardEvent) { 228 + if (pending.value) return 229 + if (event.key === 'Enter' || event.key === ' ') { 230 + event.preventDefault() 231 + // Toggle between major and minor 232 + groupingMode.value = groupingMode.value === 'major' ? 'minor' : 'major' 233 + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { 234 + event.preventDefault() 235 + // Arrow keys also toggle 236 + groupingMode.value = groupingMode.value === 'major' ? 'minor' : 'major' 237 + } 238 + } 239 + 240 + // Calculate last week date range (matches npm's "last-week" API) 241 + const startDate = computed(() => { 242 + const today = new Date() 243 + const yesterday = new Date( 244 + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 1), 245 + ) 246 + const startObj = new Date(yesterday) 247 + startObj.setUTCDate(startObj.getUTCDate() - 6) 248 + return startObj.toISOString().slice(0, 10) 249 + }) 250 + 251 + const endDate = computed(() => { 252 + const today = new Date() 253 + const yesterday = new Date( 254 + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 1), 255 + ) 256 + return yesterday.toISOString().slice(0, 10) 257 + }) 258 + </script> 259 + 260 + <template> 261 + <div 262 + class="w-full flex flex-col" 263 + id="version-distribution" 264 + :aria-busy="pending ? 'true' : 'false'" 265 + > 266 + <div class="w-full mb-4 flex flex-col gap-3"> 267 + <div class="flex flex-col sm:flex-row gap-3 sm:gap-2 sm:items-end"> 268 + <div class="flex flex-col gap-1 sm:shrink-0"> 269 + <label class="text-3xs font-mono text-fg-subtle tracking-wide uppercase"> 270 + {{ $t('package.versions.distribution_title') }} 271 + </label> 272 + <div 273 + class="flex items-center bg-bg-subtle border border-border rounded-md" 274 + role="group" 275 + :aria-label="$t('package.versions.distribution_title')" 276 + tabindex="0" 277 + @keydown="handleGroupingKeydown" 278 + > 279 + <button 280 + type="button" 281 + :class="[ 282 + 'px-4 py-1.75 font-mono text-sm transition-colors rounded-s-md', 283 + groupingMode === 'major' 284 + ? 'bg-accent text-bg font-medium' 285 + : 'text-fg-subtle hover:text-fg hover:bg-bg-subtle/50', 286 + ]" 287 + :aria-pressed="groupingMode === 'major'" 288 + :disabled="pending" 289 + tabindex="-1" 290 + @click="groupingMode = 'major'" 291 + > 292 + {{ $t('package.versions.grouping_major') }} 293 + </button> 294 + <button 295 + type="button" 296 + :class="[ 297 + 'px-4 py-1.75 font-mono text-sm transition-colors rounded-e-md border-is border-border', 298 + groupingMode === 'minor' 299 + ? 'bg-accent text-bg font-medium' 300 + : 'text-fg-subtle hover:text-fg hover:bg-bg-subtle/50', 301 + ]" 302 + :aria-pressed="groupingMode === 'minor'" 303 + :disabled="pending" 304 + tabindex="-1" 305 + @click="groupingMode = 'minor'" 306 + > 307 + {{ $t('package.versions.grouping_minor') }} 308 + </button> 309 + </div> 310 + </div> 311 + 312 + <div class="grid grid-cols-2 gap-2 flex-1"> 313 + <TooltipApp 314 + :text="$t('package.versions.date_range_tooltip')" 315 + position="bottom" 316 + :to="inModal ? '#chart-modal' : undefined" 317 + :offset="8" 318 + class="w-full" 319 + > 320 + <div class="flex flex-col gap-1 w-full"> 321 + <label 322 + for="versionDistStartDate" 323 + class="text-3xs font-mono text-fg-subtle tracking-wide uppercase" 324 + > 325 + {{ $t('package.trends.start_date') }} 326 + </label> 327 + <div class="relative flex items-center"> 328 + <span 329 + class="absolute inset-is-2 i-carbon:calendar w-4 h-4 text-fg-subtle shrink-0 pointer-events-none" 330 + aria-hidden="true" 331 + /> 332 + <InputBase 333 + id="versionDistStartDate" 334 + :model-value="startDate" 335 + disabled 336 + type="date" 337 + class="w-full min-w-0 bg-transparent ps-7" 338 + size="medium" 339 + /> 340 + </div> 341 + </div> 342 + </TooltipApp> 343 + 344 + <TooltipApp 345 + :text="$t('package.versions.date_range_tooltip')" 346 + position="bottom" 347 + :to="inModal ? '#chart-modal' : undefined" 348 + :offset="8" 349 + class="w-full" 350 + > 351 + <div class="flex flex-col gap-1 w-full"> 352 + <label 353 + for="versionDistEndDate" 354 + class="text-3xs font-mono text-fg-subtle tracking-wide uppercase" 355 + > 356 + {{ $t('package.trends.end_date') }} 357 + </label> 358 + <div class="relative flex items-center"> 359 + <span 360 + class="absolute inset-is-2 i-carbon:calendar w-4 h-4 text-fg-subtle shrink-0 pointer-events-none" 361 + aria-hidden="true" 362 + /> 363 + <InputBase 364 + id="versionDistEndDate" 365 + :model-value="endDate" 366 + disabled 367 + type="date" 368 + class="w-full min-w-0 bg-transparent ps-7" 369 + size="medium" 370 + /> 371 + </div> 372 + </div> 373 + </TooltipApp> 374 + </div> 375 + </div> 376 + 377 + <div class="flex flex-col gap-4 w-full max-w-1/2"> 378 + <TooltipApp 379 + :text="$t('package.versions.recent_versions_only_tooltip')" 380 + position="bottom" 381 + :to="inModal ? '#chart-modal' : undefined" 382 + :offset="8" 383 + > 384 + <SettingsToggle 385 + v-model="showRecentOnly" 386 + :label="$t('package.versions.recent_versions_only')" 387 + justify="start" 388 + reverse-order 389 + :class="pending ? 'opacity-50 pointer-events-none' : ''" 390 + /> 391 + </TooltipApp> 392 + 393 + <TooltipApp 394 + :text="$t('package.versions.show_low_usage_tooltip')" 395 + position="bottom" 396 + :to="inModal ? '#chart-modal' : undefined" 397 + :offset="8" 398 + > 399 + <SettingsToggle 400 + v-model="showLowUsageVersions" 401 + :label="$t('package.versions.show_low_usage')" 402 + justify="start" 403 + reverse-order 404 + :class="pending ? 'opacity-50 pointer-events-none' : ''" 405 + /> 406 + </TooltipApp> 407 + </div> 408 + </div> 409 + 410 + <h2 id="version-distribution-title" class="sr-only"> 411 + {{ $t('package.versions.distribution_title') }} 412 + </h2> 413 + 414 + <div 415 + role="region" 416 + aria-labelledby="version-distribution-title" 417 + class="relative" 418 + :class="isMobile ? 'min-h-[500px]' : 'min-h-[400px]'" 419 + > 420 + <!-- Chart content --> 421 + <ClientOnly v-if="xyDataset.length > 0 && !error"> 422 + <div class="chart-container w-full" :key="groupingMode"> 423 + <VueUiXy :dataset="xyDataset" :config="chartConfig" class="[direction:ltr]"> 424 + <!-- Injecting custom svg elements --> 425 + <template #svg="{ svg }"> 426 + <!-- Inject legend during SVG print only --> 427 + <g v-if="svg.isPrintingSvg" v-html="drawSvgPrintLegend(svg, watermarkColors)" /> 428 + 429 + <!-- Inject npmx logo & tagline during SVG and PNG print --> 430 + <g 431 + v-if="svg.isPrintingSvg || svg.isPrintingImg" 432 + v-html=" 433 + drawNpmxLogoAndTaglineWatermark(svg, watermarkColors, $t, 'belowDrawingArea') 434 + " 435 + /> 436 + </template> 437 + 438 + <!-- Subtle gradient applied for area charts --> 439 + <template #area-gradient="{ series: chartModalSeries, id: gradientId }"> 440 + <linearGradient :id="gradientId" x1="0" x2="0" y1="0" y2="1"> 441 + <stop offset="0%" :stop-color="chartModalSeries.color" stop-opacity="0.2" /> 442 + <stop offset="100%" :stop-color="colors.bg" stop-opacity="0" /> 443 + </linearGradient> 444 + </template> 445 + 446 + <!-- Custom legend for single series (non-interactive) --> 447 + <template #legend="{ legend }"> 448 + <div class="flex gap-4 flex-wrap justify-center"> 449 + <template v-if="legend.length > 0"> 450 + <div class="flex gap-1 place-items-center"> 451 + <div class="h-3 w-3"> 452 + <svg viewBox="0 0 2 2" class="w-full"> 453 + <rect x="0" y="0" width="2" height="2" rx="0.3" :fill="legend[0]?.color" /> 454 + </svg> 455 + </div> 456 + <span> 457 + {{ legend[0]?.name }} 458 + </span> 459 + </div> 460 + </template> 461 + </div> 462 + </template> 463 + 464 + <!-- Contextual menu icon --> 465 + <template #menuIcon="{ isOpen }"> 466 + <span v-if="isOpen" class="i-carbon:close w-6 h-6" aria-hidden="true" /> 467 + <span v-else class="i-carbon:overflow-menu-vertical w-6 h-6" aria-hidden="true" /> 468 + </template> 469 + 470 + <!-- Export options --> 471 + <template #optionCsv> 472 + <span 473 + class="i-carbon:csv w-6 h-6 text-fg-subtle" 474 + style="pointer-events: none" 475 + aria-hidden="true" 476 + /> 477 + </template> 478 + 479 + <template #optionImg> 480 + <span 481 + class="i-carbon:png w-6 h-6 text-fg-subtle" 482 + style="pointer-events: none" 483 + aria-hidden="true" 484 + /> 485 + </template> 486 + 487 + <template #optionSvg> 488 + <span 489 + class="i-carbon:svg w-6 h-6 text-fg-subtle" 490 + style="pointer-events: none" 491 + aria-hidden="true" 492 + /> 493 + </template> 494 + 495 + <!-- Annotator action icons --> 496 + <template #annotator-action-close> 497 + <span 498 + class="i-carbon:close w-6 h-6 text-fg-subtle" 499 + style="pointer-events: none" 500 + aria-hidden="true" 501 + /> 502 + </template> 503 + 504 + <template #annotator-action-color="{ color }"> 505 + <span class="i-carbon:color-palette w-6 h-6" :style="{ color }" aria-hidden="true" /> 506 + </template> 507 + 508 + <template #annotator-action-undo> 509 + <span 510 + class="i-carbon:undo w-6 h-6 text-fg-subtle" 511 + style="pointer-events: none" 512 + aria-hidden="true" 513 + /> 514 + </template> 515 + 516 + <template #annotator-action-redo> 517 + <span 518 + class="i-carbon:redo w-6 h-6 text-fg-subtle" 519 + style="pointer-events: none" 520 + aria-hidden="true" 521 + /> 522 + </template> 523 + 524 + <template #annotator-action-delete> 525 + <span 526 + class="i-carbon:trash-can w-6 h-6 text-fg-subtle" 527 + style="pointer-events: none" 528 + aria-hidden="true" 529 + /> 530 + </template> 531 + 532 + <template #optionAnnotator="{ isAnnotator }"> 533 + <span 534 + v-if="isAnnotator" 535 + class="i-carbon:edit-off w-6 h-6 text-fg-subtle" 536 + style="pointer-events: none" 537 + aria-hidden="true" 538 + /> 539 + <span 540 + v-else 541 + class="i-carbon:edit w-6 h-6 text-fg-subtle" 542 + style="pointer-events: none" 543 + aria-hidden="true" 544 + /> 545 + </template> 546 + </VueUiXy> 547 + </div> 548 + 549 + <template #fallback> 550 + <div /> 551 + </template> 552 + </ClientOnly> 553 + 554 + <!-- No-data state --> 555 + <div v-if="!hasData && !pending && !error" class="flex items-center justify-center h-full"> 556 + <div class="text-sm text-fg-subtle font-mono text-center flex flex-col items-center gap-2"> 557 + <span class="i-carbon:data-vis-4 w-8 h-8" /> 558 + <p>{{ $t('package.trends.no_data') }}</p> 559 + </div> 560 + </div> 561 + 562 + <!-- Error state --> 563 + <div v-if="error" class="flex items-center justify-center h-full" role="alert"> 564 + <div class="text-sm text-fg-subtle font-mono text-center flex flex-col items-center gap-2"> 565 + <span class="i-carbon:warning-hex w-8 h-8 text-red-400" /> 566 + <p>{{ error.message }}</p> 567 + <p class="text-xs">Package: {{ packageName }}</p> 568 + </div> 569 + </div> 570 + 571 + <!-- Loading indicator as true overlay --> 572 + <div 573 + v-if="showLoadingIndicator" 574 + role="status" 575 + aria-live="polite" 576 + class="absolute top-1/2 inset-is-1/2 -translate-x-1/2 -translate-y-1/2" 577 + > 578 + <div 579 + class="text-xs text-fg-subtle font-mono bg-bg/70 backdrop-blur px-3 py-2 rounded-md border border-border" 580 + > 581 + {{ $t('common.loading') }} 582 + </div> 583 + </div> 584 + </div> 585 + </div> 586 + </template> 587 + 588 + <style scoped> 589 + /* Disable all transitions on SVG elements to prevent repositioning animation */ 590 + :deep(.vue-ui-xy) svg rect { 591 + transition: none !important; 592 + } 593 + 594 + @keyframes fadeInUp { 595 + from { 596 + opacity: 0; 597 + transform: translateY(8px); 598 + } 599 + to { 600 + opacity: 1; 601 + transform: translateY(0); 602 + } 603 + } 604 + 605 + .chart-container { 606 + animation: fadeInUp 350ms cubic-bezier(0.4, 0, 0.2, 1); 607 + } 608 + </style> 609 + 610 + <style> 611 + /* Override default placement of the refresh button to have it to the minimap's side */ 612 + @media screen and (min-width: 767px) { 613 + #version-distribution .vue-data-ui-refresh-button { 614 + top: -0.6rem !important; 615 + left: calc(100% + 2rem) !important; 616 + } 617 + } 618 + </style>
+82 -5
app/components/Package/Versions.vue
··· 19 19 time: Record<string, string> 20 20 }>() 21 21 22 + const chartModal = useModal('chart-modal') 23 + const hasDistributionModalTransitioned = shallowRef(false) 24 + const isDistributionModalOpen = shallowRef(false) 25 + let distributionModalFallbackTimer: ReturnType<typeof setTimeout> | null = null 26 + 27 + function clearDistributionModalFallbackTimer() { 28 + if (distributionModalFallbackTimer) { 29 + clearTimeout(distributionModalFallbackTimer) 30 + distributionModalFallbackTimer = null 31 + } 32 + } 33 + 34 + async function openDistributionModal() { 35 + isDistributionModalOpen.value = true 36 + hasDistributionModalTransitioned.value = false 37 + // ensure the component renders before opening the dialog 38 + await nextTick() 39 + chartModal.open() 40 + 41 + // Fallback: Force mount if transition event doesn't fire 42 + clearDistributionModalFallbackTimer() 43 + distributionModalFallbackTimer = setTimeout(() => { 44 + if (!hasDistributionModalTransitioned.value) { 45 + hasDistributionModalTransitioned.value = true 46 + } 47 + }, 500) 48 + } 49 + 50 + function closeDistributionModal() { 51 + isDistributionModalOpen.value = false 52 + hasDistributionModalTransitioned.value = false 53 + clearDistributionModalFallbackTimer() 54 + } 55 + 56 + function handleDistributionModalTransitioned() { 57 + hasDistributionModalTransitioned.value = true 58 + clearDistributionModalFallbackTimer() 59 + } 60 + 22 61 /** Maximum number of dist-tag rows to show before collapsing into "Other versions" */ 23 62 const MAX_VISIBLE_TAGS = 10 24 63 ··· 304 343 id="versions" 305 344 > 306 345 <template #actions> 307 - <LinkBase 308 - variant="button-secondary" 309 - :to="`https://majors.nullvoxpopuli.com/q?packages=${packageName}`" 346 + <ButtonBase 347 + variant="secondary" 348 + class="text-fg-subtle hover:text-fg transition-colors duration-200 inline-flex items-center justify-center min-w-6 min-h-6 -m-1 p-1 rounded" 310 349 :title="$t('package.downloads.community_distribution')" 311 350 classicon="i-carbon:load-balancer-network" 351 + @click="openDistributionModal" 312 352 > 313 353 <span class="sr-only">{{ $t('package.downloads.community_distribution') }}</span> 314 - </LinkBase> 354 + </ButtonBase> 315 355 </template> 316 356 <div class="space-y-0.5 min-w-0"> 317 357 <!-- Dist-tag rows (limited to MAX_VISIBLE_TAGS) --> ··· 569 609 <div class="flex items-center gap-2 min-w-0"> 570 610 <button 571 611 type="button" 572 - class="w-4 h-4 flex items-center justify-center text-fg-subtle hover:text-fg transition-colors shrink-0 focus-visible:outline-accent/70 rounded-sm" 612 + class="w-4 h-4 flex items-center justify-center text-fg-subtle hover:text-fg transition-colors shrink-0 rounded-sm" 573 613 :aria-expanded="expandedMajorGroups.has(group.groupKey)" 574 614 :aria-label=" 575 615 expandedMajorGroups.has(group.groupKey) ··· 771 811 </div> 772 812 </div> 773 813 </CollapsibleSection> 814 + 815 + <!-- Version Distribution Modal --> 816 + <PackageChartModal 817 + v-if="isDistributionModalOpen" 818 + :title="$t('package.versions.distribution_modal_title')" 819 + @close="closeDistributionModal" 820 + @transitioned="handleDistributionModalTransitioned" 821 + > 822 + <!-- The Chart is mounted after the dialog has transitioned --> 823 + <!-- This avoids flaky behavior and ensures proper modal lifecycle --> 824 + <Transition name="opacity" mode="out-in"> 825 + <PackageVersionDistribution 826 + v-if="hasDistributionModalTransitioned" 827 + :package-name="packageName" 828 + :in-modal="true" 829 + /> 830 + </Transition> 831 + 832 + <!-- This placeholder bears the same dimensions as the VersionDistribution component --> 833 + <!-- Avoids CLS when the dialog has transitioned --> 834 + <div 835 + v-if="!hasDistributionModalTransitioned" 836 + class="w-full aspect-[272/609] sm:aspect-[671/516]" 837 + /> 838 + </PackageChartModal> 774 839 </template> 840 + 841 + <style scoped> 842 + .opacity-enter-active, 843 + .opacity-leave-active { 844 + transition: opacity 200ms ease; 845 + } 846 + 847 + .opacity-enter-from, 848 + .opacity-leave-to { 849 + opacity: 0; 850 + } 851 + </style>
+1
app/components/Package/WeeklyDownloadStats.vue
··· 271 271 272 272 <PackageChartModal 273 273 v-if="isChartModalOpen && hasWeeklyDownloads" 274 + :title="$t('package.downloads.modal_title')" 274 275 @close="handleModalClose" 275 276 @transitioned="handleModalTransitioned" 276 277 >
+96 -25
app/components/Settings/Toggle.client.vue
··· 1 1 <script setup lang="ts"> 2 - defineProps<{ 3 - label?: string 4 - description?: string 5 - class?: string 6 - }>() 2 + import TooltipApp from '~/components/Tooltip/App.vue' 3 + 4 + const props = withDefaults( 5 + defineProps<{ 6 + label?: string 7 + description?: string 8 + class?: string 9 + justify?: 'between' | 'start' 10 + tooltip?: string 11 + tooltipPosition?: 'top' | 'bottom' | 'left' | 'right' 12 + tooltipTo?: string 13 + tooltipOffset?: number 14 + reverseOrder?: boolean 15 + }>(), 16 + { 17 + justify: 'between', 18 + reverseOrder: false, 19 + }, 20 + ) 7 21 8 22 const checked = defineModel<boolean>({ 9 23 default: false, ··· 13 27 <template> 14 28 <button 15 29 type="button" 16 - class="w-full flex items-center justify-between gap-4 group focus-visible:outline-none py-1 -my-1" 30 + class="w-full flex items-center gap-4 group focus-visible:outline-none py-1 -my-1" 31 + :class="[justify === 'start' ? 'justify-start' : 'justify-between', $props.class]" 17 32 role="switch" 18 33 :aria-checked="checked" 19 34 @click="checked = !checked" 20 - :class="class" 21 35 > 22 - <span v-if="label" class="text-sm text-fg font-medium text-start"> 23 - {{ label }} 24 - </span> 25 - <span 26 - class="inline-flex items-center h-6 w-11 shrink-0 rounded-full border p-0.25 transition-colors duration-200 shadow-sm ease-in-out motion-reduce:transition-none group-focus-visible:(outline-accent/70 outline-offset-2 outline-solid)" 27 - :class=" 28 - checked 29 - ? 'bg-accent border-accent group-hover:bg-accent/80' 30 - : 'bg-fg/50 border-fg/50 group-hover:bg-fg/70' 31 - " 32 - aria-hidden="true" 33 - > 36 + <template v-if="props.reverseOrder"> 37 + <span 38 + class="inline-flex items-center h-6 w-11 shrink-0 rounded-full border p-0.25 transition-colors duration-200 shadow-sm ease-in-out motion-reduce:transition-none group-focus-visible:(outline-accent/70 outline-offset-2 outline-solid)" 39 + :class=" 40 + checked 41 + ? 'bg-accent border-accent group-hover:bg-accent/80' 42 + : 'bg-fg/50 border-fg/50 group-hover:bg-fg/70' 43 + " 44 + aria-hidden="true" 45 + > 46 + <span 47 + class="block h-5 w-5 rounded-full bg-bg shadow-sm transition-transform duration-200 ease-in-out motion-reduce:transition-none" 48 + /> 49 + </span> 50 + <TooltipApp 51 + v-if="tooltip && label" 52 + :text="tooltip" 53 + :position="tooltipPosition ?? 'top'" 54 + :to="tooltipTo" 55 + :offset="tooltipOffset" 56 + > 57 + <span class="text-sm text-fg font-medium text-start"> 58 + {{ label }} 59 + </span> 60 + </TooltipApp> 61 + <span v-else-if="label" class="text-sm text-fg font-medium text-start"> 62 + {{ label }} 63 + </span> 64 + </template> 65 + <template v-else> 66 + <TooltipApp 67 + v-if="tooltip && label" 68 + :text="tooltip" 69 + :position="tooltipPosition ?? 'top'" 70 + :to="tooltipTo" 71 + :offset="tooltipOffset" 72 + > 73 + <span class="text-sm text-fg font-medium text-start"> 74 + {{ label }} 75 + </span> 76 + </TooltipApp> 77 + <span v-else-if="label" class="text-sm text-fg font-medium text-start"> 78 + {{ label }} 79 + </span> 34 80 <span 35 - class="block h-5 w-5 rounded-full bg-bg shadow-sm transition-transform duration-200 ease-in-out motion-reduce:transition-none" 36 - /> 37 - </span> 81 + class="inline-flex items-center h-6 w-11 shrink-0 rounded-full border p-0.25 transition-colors duration-200 shadow-sm ease-in-out motion-reduce:transition-none group-focus-visible:(outline-accent/70 outline-offset-2 outline-solid)" 82 + :class=" 83 + checked 84 + ? 'bg-accent border-accent group-hover:bg-accent/80' 85 + : 'bg-fg/50 border-fg/50 group-hover:bg-fg/70' 86 + " 87 + aria-hidden="true" 88 + > 89 + <span 90 + class="block h-5 w-5 rounded-full bg-bg shadow-sm transition-transform duration-200 ease-in-out motion-reduce:transition-none" 91 + /> 92 + </span> 93 + </template> 38 94 </button> 39 95 <p v-if="description" class="text-sm text-fg-muted mt-2"> 40 96 {{ description }} ··· 42 98 </template> 43 99 44 100 <style scoped> 101 + /* Default order: label first, toggle last */ 45 102 button[aria-checked='false'] > span:last-of-type > span { 46 103 translate: 0; 47 104 } ··· 52 109 translate: calc(-100%); 53 110 } 54 111 112 + /* Reverse order: toggle first, label last */ 113 + button[aria-checked='false'] > span:first-of-type > span { 114 + translate: 0; 115 + } 116 + button[aria-checked='true'] > span:first-of-type > span { 117 + translate: calc(100%); 118 + } 119 + html[dir='rtl'] button[aria-checked='true'] > span:first-of-type > span { 120 + translate: calc(-100%); 121 + } 122 + 55 123 @media (forced-colors: active) { 56 124 /* make toggle tracks and thumb visible in forced colors. */ 57 125 button[role='switch'] { 58 - & > span:last-of-type { 126 + & > span:last-of-type, 127 + & > span:first-of-type { 59 128 forced-color-adjust: none; 60 129 } 61 130 62 - &[aria-checked='false'] > span:last-of-type { 131 + &[aria-checked='false'] > span:last-of-type, 132 + &[aria-checked='false'] > span:first-of-type { 63 133 background: Canvas; 64 134 border-color: CanvasText; 65 135 ··· 68 138 } 69 139 } 70 140 71 - &[aria-checked='true'] > span:last-of-type { 141 + &[aria-checked='true'] > span:last-of-type, 142 + &[aria-checked='true'] > span:first-of-type { 72 143 background: Highlight; 73 144 border-color: Highlight; 74 145
+4 -1
app/components/Tooltip/App.vue
··· 8 8 interactive?: boolean 9 9 /** Teleport target for the tooltip content (defaults to 'body') */ 10 10 to?: string | HTMLElement 11 + /** Offset distance in pixels (default: 4) */ 12 + offset?: number 11 13 }>() 12 14 13 15 const isVisible = shallowRef(false) ··· 49 51 :isVisible 50 52 :position 51 53 :interactive 54 + :to 55 + :offset 52 56 :tooltip-attr="tooltipAttrs" 53 - :to="props.to" 54 57 @mouseenter="show" 55 58 @mouseleave="hide" 56 59 @focusin="show"
+4 -1
app/components/Tooltip/Base.vue
··· 17 17 tooltipAttr?: HTMLAttributes 18 18 /** Teleport target for the tooltip content (defaults to 'body') */ 19 19 to?: string | HTMLElement 20 + /** Offset distance in pixels (default: 4) */ 21 + offset?: number 20 22 /** Strategy for the tooltip - prefer fixed for sticky containers (defaults to 'absolute') */ 21 23 strategy?: Strategy 22 24 }>(), 23 25 { 24 26 to: 'body', 27 + offset: 4, 25 28 strategy: 'absolute', 26 29 }, 27 30 ) ··· 35 38 placement, 36 39 whileElementsMounted: autoUpdate, 37 40 strategy: props.strategy, 38 - middleware: [offset(4), flip(), shift({ padding: 8 })], 41 + middleware: [offset(props.offset), flip(), shift({ padding: 8 })], 39 42 }) 40 43 </script> 41 44
+90
app/composables/useChartWatermark.ts
··· 1 + /** 2 + * Shared utilities for chart watermarks and legends in SVG/PNG exports 3 + */ 4 + 5 + interface WatermarkColors { 6 + fg: string 7 + bg: string 8 + fgSubtle: string 9 + } 10 + 11 + /** 12 + * Build and return legend as SVG for export 13 + * Legend items are displayed in a column, on the top left of the chart. 14 + */ 15 + export function drawSvgPrintLegend(svg: Record<string, any>, colors: WatermarkColors) { 16 + const data = Array.isArray(svg?.data) ? svg.data : [] 17 + if (!data.length) return '' 18 + 19 + const seriesNames: string[] = [] 20 + 21 + data.forEach((serie, index) => { 22 + seriesNames.push(` 23 + <rect 24 + x="${svg.drawingArea.left + 12}" 25 + y="${svg.drawingArea.top + 24 * index - 7}" 26 + width="12" 27 + height="12" 28 + fill="${serie.color}" 29 + rx="3" 30 + /> 31 + <text 32 + text-anchor="start" 33 + dominant-baseline="middle" 34 + x="${svg.drawingArea.left + 32}" 35 + y="${svg.drawingArea.top + 24 * index}" 36 + font-size="16" 37 + fill="${colors.fg}" 38 + stroke="${colors.bg}" 39 + stroke-width="1" 40 + paint-order="stroke fill" 41 + > 42 + ${serie.name} 43 + </text> 44 + `) 45 + }) 46 + 47 + return seriesNames.join('') 48 + } 49 + 50 + /** 51 + * Build and return npmx svg logo and tagline, to be injected during PNG & SVG exports 52 + */ 53 + export function drawNpmxLogoAndTaglineWatermark( 54 + svg: Record<string, any>, 55 + colors: WatermarkColors, 56 + translateFn: (key: string) => string, 57 + positioning: 'bottom' | 'belowDrawingArea' = 'bottom', 58 + ) { 59 + if (!svg?.drawingArea) return '' 60 + const npmxLogoWidthToHeight = 2.64 61 + const npmxLogoWidth = 100 62 + const npmxLogoHeight = npmxLogoWidth / npmxLogoWidthToHeight 63 + 64 + // Position watermark based on the positioning strategy 65 + const watermarkY = 66 + positioning === 'belowDrawingArea' 67 + ? svg.drawingArea.top + svg.drawingArea.height + 48 68 + : svg.height - npmxLogoHeight 69 + 70 + const taglineY = 71 + positioning === 'belowDrawingArea' ? watermarkY - 6 : svg.height - npmxLogoHeight - 6 72 + 73 + // Center the watermark horizontally relative to the full SVG width 74 + const watermarkX = svg.width / 2 - npmxLogoWidth / 2 75 + 76 + return ` 77 + <svg x="${watermarkX}" y="${watermarkY}" width="${npmxLogoWidth}" height="${npmxLogoHeight}" viewBox="0 0 330 125" fill="none" xmlns="http://www.w3.org/2000/svg"> 78 + <path d="M22.848 97V85.288H34.752V97H22.848ZM56.4105 107.56L85.5945 25H93.2745L64.0905 107.56H56.4105ZM121.269 97V46.12H128.661L128.949 59.08L127.989 58.216C128.629 55.208 129.781 52.744 131.445 50.824C133.173 48.84 135.221 47.368 137.589 46.408C139.957 45.448 142.453 44.968 145.077 44.968C148.981 44.968 152.213 45.832 154.773 47.56C157.397 49.288 159.381 51.624 160.725 54.568C162.069 57.448 162.741 60.68 162.741 64.264V97H154.677V66.568C154.677 61.832 153.749 58.248 151.893 55.816C150.037 53.32 147.189 52.072 143.349 52.072C140.725 52.072 138.357 52.648 136.245 53.8C134.133 54.888 132.437 56.52 131.157 58.696C129.941 60.808 129.333 63.432 129.333 66.568V97H121.269ZM173.647 111.4V46.12H181.135L181.327 57.64L180.175 57.064C181.455 53.096 183.568 50.088 186.512 48.04C189.519 45.992 192.976 44.968 196.88 44.968C201.936 44.968 206.064 46.216 209.264 48.712C212.528 51.208 214.928 54.472 216.464 58.504C218 62.536 218.767 66.888 218.767 71.56C218.767 76.232 218 80.584 216.464 84.616C214.928 88.648 212.528 91.912 209.264 94.408C206.064 96.904 201.936 98.152 196.88 98.152C194.256 98.152 191.792 97.704 189.487 96.808C187.247 95.912 185.327 94.664 183.727 93.064C182.191 91.464 181.135 89.576 180.559 87.4L181.711 86.056V111.4H173.647ZM196.111 90.472C200.528 90.472 203.984 88.808 206.48 85.48C209.04 82.152 210.319 77.512 210.319 71.56C210.319 65.608 209.04 60.968 206.48 57.64C203.984 54.312 200.528 52.648 196.111 52.648C193.167 52.648 190.607 53.352 188.431 54.76C186.319 56.168 184.655 58.28 183.439 61.096C182.287 63.912 181.711 67.4 181.711 71.56C181.711 75.72 182.287 79.208 183.439 82.024C184.591 84.84 186.255 86.952 188.431 88.36C190.607 89.768 193.167 90.472 196.111 90.472ZM222.57 97V46.12H229.962L230.25 57.448L229.29 57.256C229.866 53.48 231.082 50.504 232.938 48.328C234.858 46.088 237.29 44.968 240.234 44.968C243.242 44.968 245.546 46.056 247.146 48.232C248.81 50.408 249.834 53.608 250.218 57.832H249.258C249.834 53.864 251.114 50.728 253.098 48.424C255.146 46.12 257.706 44.968 260.778 44.968C264.874 44.968 267.85 46.376 269.706 49.192C271.562 52.008 272.49 56.68 272.49 63.208V97H264.426V64.36C264.426 59.816 263.946 56.648 262.986 54.856C262.026 53 260.522 52.072 258.474 52.072C257.13 52.072 255.946 52.52 254.922 53.416C253.898 54.248 253.066 55.592 252.426 57.448C251.85 59.304 251.562 61.672 251.562 64.552V97H243.498V64.36C243.498 60.008 243.018 56.872 242.058 54.952C241.162 53.032 239.658 52.072 237.546 52.072C236.202 52.072 235.018 52.52 233.994 53.416C232.97 54.248 232.138 55.592 231.498 57.448C230.922 59.304 230.634 61.672 230.634 64.552V97H222.57ZM276.676 97L295.396 70.888L277.636 46.12H287.044L300.388 65.32L313.444 46.12H323.044L305.38 71.08L323.908 97H314.5L300.388 76.456L286.276 97H276.676Z" fill="${colors.fg}"/> 79 + </svg> 80 + <text 81 + fill="${colors.fgSubtle}" 82 + x="${svg.width / 2}" 83 + y="${taglineY}" 84 + font-size="12" 85 + text-anchor="middle" 86 + > 87 + ${translateFn('tagline')} 88 + </text> 89 + ` 90 + }
+175
app/composables/useVersionDistribution.ts
··· 1 + import type { MaybeRefOrGetter } from 'vue' 2 + import { toValue } from 'vue' 3 + import type { 4 + VersionDistributionResponse, 5 + VersionGroupDownloads, 6 + VersionGroupingMode, 7 + } from '#shared/types/version-downloads' 8 + 9 + interface ChartDataItem { 10 + name: string 11 + downloads: number 12 + } 13 + 14 + /** 15 + * Composable for managing version download distribution data and state. 16 + * 17 + * Fetches version download statistics from the API, manages grouping/filtering state, 18 + * and formats data for chart visualization. 19 + * 20 + * @param packageName - The package name to fetch version downloads for 21 + * @returns Reactive state and computed chart data 22 + */ 23 + export function useVersionDistribution(packageName: MaybeRefOrGetter<string>) { 24 + const groupingMode = ref<VersionGroupingMode>('major') 25 + const showRecentOnly = ref(false) 26 + const showLowUsageVersions = ref(false) 27 + const pending = ref(false) 28 + const error = ref<Error | null>(null) 29 + const data = ref<VersionDistributionResponse | null>(null) 30 + 31 + /** 32 + * Fetches version download distribution from the API 33 + */ 34 + async function fetchDistribution() { 35 + const pkgName = toValue(packageName) 36 + if (!pkgName) { 37 + data.value = null 38 + return 39 + } 40 + 41 + pending.value = true 42 + error.value = null 43 + 44 + try { 45 + const mode = groupingMode.value 46 + const response = await $fetch<VersionDistributionResponse>( 47 + `/api/registry/downloads/${encodeURIComponent(pkgName)}/versions`, 48 + { 49 + query: { 50 + mode, 51 + filterOldVersions: showRecentOnly.value ? 'true' : 'false', 52 + filterThreshold: showLowUsageVersions.value ? '0' : '1', 53 + }, 54 + cache: 'default', // Don't force-cache since query params change frequently 55 + }, 56 + ) 57 + 58 + data.value = response 59 + } catch (err) { 60 + error.value = err instanceof Error ? err : new Error('Failed to fetch version distribution') 61 + data.value = null 62 + } finally { 63 + pending.value = false 64 + } 65 + } 66 + 67 + /** 68 + * Applies filtering to version groups based on current filter settings 69 + * Sorts groups from oldest to newest version 70 + */ 71 + const filteredGroups = computed<VersionGroupDownloads[]>(() => { 72 + if (!data.value) return [] 73 + 74 + let groups = data.value.groups 75 + 76 + // Filter using server-provided recent versions list 77 + if (showRecentOnly.value && data.value.recentVersions) { 78 + const recentVersionsSet = new Set(data.value.recentVersions) 79 + 80 + groups = groups.filter(group => { 81 + return group.versions.some(v => { 82 + // Check exact version match 83 + if (recentVersionsSet.has(v.version)) return true 84 + 85 + // Also check base version (strip prerelease suffix) 86 + if (v.version.includes('-')) { 87 + const baseVersion = v.version.split('-')[0] 88 + if (baseVersion && recentVersionsSet.has(baseVersion)) return true 89 + } 90 + 91 + return false 92 + }) 93 + }) 94 + } 95 + 96 + // Sort groups from oldest to newest by parsing version numbers 97 + return groups.slice().sort((a, b) => { 98 + // Extract version numbers from groupKey (e.g., "1.x" or "1.2.x") 99 + const aParts = a.groupKey.replace(/\.x$/, '').split('.').map(Number) 100 + const bParts = b.groupKey.replace(/\.x$/, '').split('.').map(Number) 101 + 102 + for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { 103 + const aPart = aParts[i] ?? 0 104 + const bPart = bParts[i] ?? 0 105 + if (aPart !== bPart) { 106 + return aPart - bPart 107 + } 108 + } 109 + return 0 110 + }) 111 + }) 112 + 113 + const chartDataset = computed<ChartDataItem[]>(() => { 114 + const groups = filteredGroups.value 115 + if (!groups.length) return [] 116 + 117 + return groups.map(group => ({ 118 + name: group.label, 119 + downloads: group.downloads, 120 + })) 121 + }) 122 + 123 + const totalDownloads = computed(() => { 124 + const groups = filteredGroups.value 125 + if (!groups || !groups.length) return 0 126 + return groups.reduce((sum, group) => sum + group.downloads, 0) 127 + }) 128 + 129 + const hasData = computed(() => { 130 + return data.value !== null && filteredGroups.value.length > 0 131 + }) 132 + 133 + // Refetch when filter changes - no immediate since we already have data 134 + watch(showRecentOnly, () => { 135 + fetchDistribution() 136 + }) 137 + 138 + watch(showLowUsageVersions, () => { 139 + fetchDistribution() 140 + }) 141 + 142 + // Refetch when grouping mode changes - immediate to load initial data 143 + watch( 144 + groupingMode, 145 + () => { 146 + fetchDistribution() 147 + }, 148 + { immediate: true }, 149 + ) 150 + 151 + // Refetch when package name changes - not immediate since parent component controls initialization 152 + watch( 153 + () => toValue(packageName), 154 + () => { 155 + fetchDistribution() 156 + }, 157 + { immediate: false }, 158 + ) 159 + 160 + return { 161 + // State 162 + groupingMode, 163 + showRecentOnly, 164 + showLowUsageVersions, 165 + pending, 166 + error, 167 + // Computed 168 + filteredGroups, 169 + chartDataset, 170 + totalDownloads, 171 + hasData, 172 + // Methods 173 + fetchDistribution, 174 + } 175 + }
+11 -1
i18n/locales/en.json
··· 287 287 "more_tagged": "{count} more tagged", 288 288 "all_covered": "All versions are covered by tags above", 289 289 "deprecated_title": "{version} (deprecated)", 290 - "view_all": "View {count} version | View all {count} versions" 290 + "view_all": "View {count} version | View all {count} versions", 291 + "distribution_title": "Semver Group", 292 + "distribution_modal_title": "Versions", 293 + "grouping_major": "Major", 294 + "grouping_minor": "Minor", 295 + "recent_versions_only": "Recent versions only", 296 + "recent_versions_only_tooltip": "Show only versions published within the last year.", 297 + "show_low_usage": "Show low usage versions", 298 + "show_low_usage_tooltip": "Include version groups with less than 1% of total downloads", 299 + "date_range_tooltip": "Last week of version distributions only" 291 300 }, 292 301 "dependencies": { 293 302 "title": "Dependency ({count}) | Dependencies ({count})", ··· 348 357 }, 349 358 "downloads": { 350 359 "title": "Weekly Downloads", 360 + "modal_title": "Weekly Downloads", 351 361 "analyze": "Analyze downloads", 352 362 "community_distribution": "View community adoption distribution" 353 363 },
+30
i18n/schema.json
··· 867 867 }, 868 868 "view_all": { 869 869 "type": "string" 870 + }, 871 + "distribution_title": { 872 + "type": "string" 873 + }, 874 + "distribution_modal_title": { 875 + "type": "string" 876 + }, 877 + "grouping_major": { 878 + "type": "string" 879 + }, 880 + "grouping_minor": { 881 + "type": "string" 882 + }, 883 + "recent_versions_only": { 884 + "type": "string" 885 + }, 886 + "recent_versions_only_tooltip": { 887 + "type": "string" 888 + }, 889 + "show_low_usage": { 890 + "type": "string" 891 + }, 892 + "show_low_usage_tooltip": { 893 + "type": "string" 894 + }, 895 + "date_range_tooltip": { 896 + "type": "string" 870 897 } 871 898 }, 872 899 "additionalProperties": false ··· 1046 1073 "type": "object", 1047 1074 "properties": { 1048 1075 "title": { 1076 + "type": "string" 1077 + }, 1078 + "modal_title": { 1049 1079 "type": "string" 1050 1080 }, 1051 1081 "analyze": {
+11 -1
lunaria/files/en-GB.json
··· 286 286 "more_tagged": "{count} more tagged", 287 287 "all_covered": "All versions are covered by tags above", 288 288 "deprecated_title": "{version} (deprecated)", 289 - "view_all": "View {count} version | View all {count} versions" 289 + "view_all": "View {count} version | View all {count} versions", 290 + "distribution_title": "Semver Group", 291 + "distribution_modal_title": "Versions", 292 + "grouping_major": "Major", 293 + "grouping_minor": "Minor", 294 + "recent_versions_only": "Recent versions only", 295 + "recent_versions_only_tooltip": "Show only versions published within the last year.", 296 + "show_low_usage": "Show low usage versions", 297 + "show_low_usage_tooltip": "Include version groups with less than 1% of total downloads", 298 + "date_range_tooltip": "Last week of version distributions only" 290 299 }, 291 300 "dependencies": { 292 301 "title": "Dependency ({count}) | Dependencies ({count})", ··· 347 356 }, 348 357 "downloads": { 349 358 "title": "Weekly Downloads", 359 + "modal_title": "Weekly Downloads", 350 360 "analyze": "Analyze downloads", 351 361 "community_distribution": "View community adoption distribution" 352 362 },
+11 -1
lunaria/files/en-US.json
··· 286 286 "more_tagged": "{count} more tagged", 287 287 "all_covered": "All versions are covered by tags above", 288 288 "deprecated_title": "{version} (deprecated)", 289 - "view_all": "View {count} version | View all {count} versions" 289 + "view_all": "View {count} version | View all {count} versions", 290 + "distribution_title": "Semver Group", 291 + "distribution_modal_title": "Versions", 292 + "grouping_major": "Major", 293 + "grouping_minor": "Minor", 294 + "recent_versions_only": "Recent versions only", 295 + "recent_versions_only_tooltip": "Show only versions published within the last year.", 296 + "show_low_usage": "Show low usage versions", 297 + "show_low_usage_tooltip": "Include version groups with less than 1% of total downloads", 298 + "date_range_tooltip": "Last week of version distributions only" 290 299 }, 291 300 "dependencies": { 292 301 "title": "Dependency ({count}) | Dependencies ({count})", ··· 347 356 }, 348 357 "downloads": { 349 358 "title": "Weekly Downloads", 359 + "modal_title": "Weekly Downloads", 350 360 "analyze": "Analyze downloads", 351 361 "community_distribution": "View community adoption distribution" 352 362 },
+7
nuxt.config.ts
··· 101 101 allowQuery: ['color', 'labelColor', 'label', 'name'], 102 102 }, 103 103 }, 104 + '/api/registry/downloads/**': { 105 + isr: { 106 + expiration: 60 * 60 /* one hour */, 107 + passQuery: true, 108 + allowQuery: ['mode', 'filterOldVersions', 'filterThreshold'], 109 + }, 110 + }, 104 111 '/api/registry/docs/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 105 112 '/api/registry/file/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 106 113 '/api/registry/provenance/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
+152
server/api/registry/downloads/[...slug].get.ts
··· 1 + import { getQuery } from 'h3' 2 + import * as v from 'valibot' 3 + import { hash } from 'ohash' 4 + import type { VersionDistributionResponse } from '#shared/types' 5 + import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants' 6 + import { groupVersionDownloads } from '#server/utils/version-downloads' 7 + 8 + /** 9 + * Raw response from npm downloads API 10 + * GET https://api.npmjs.org/versions/{package}/last-week 11 + */ 12 + interface NpmVersionDownloadsResponse { 13 + package: string 14 + downloads: Record<string, number> 15 + } 16 + 17 + /** 18 + * Query parameter validation schema 19 + */ 20 + const QuerySchema = v.object({ 21 + mode: v.optional(v.picklist(['major', 'minor'] as const), 'major'), 22 + filterThreshold: v.optional( 23 + v.pipe( 24 + v.string(), 25 + v.toNumber(), // Fails validation on invalid conversion (e.g., "abc") instead of producing NaN 26 + v.minValue(0), // Ensure non-negative values 27 + ), 28 + ), 29 + filterOldVersions: v.optional(v.picklist(['true', 'false'] as const), 'false'), 30 + }) 31 + 32 + /** 33 + * GET /api/registry/downloads/:name/versions or /api/registry/downloads/@scope/name/versions 34 + * 35 + * Fetch per-version download statistics and group by major or minor version. 36 + * Data is cached for 1 hour with stale-while-revalidate. 37 + * 38 + * Query parameters: 39 + * - mode: 'major' | 'minor' (default: 'major') 40 + * - filterThreshold: minimum percentage to include (default: 1) 41 + * - filterOldVersions: 'true' to include only versions published in last year (default: 'false') 42 + */ 43 + export default defineCachedEventHandler( 44 + async event => { 45 + // Supports: /downloads/lodash/versions, /downloads/@scope/name/versions 46 + const slugParam = getRouterParam(event, 'slug') 47 + const pkgParamSegments = slugParam?.split('/') ?? [] 48 + 49 + const lastSegment = pkgParamSegments.at(-1) 50 + if (!lastSegment || lastSegment !== 'versions') { 51 + throw createError({ 52 + statusCode: 404, 53 + message: 'Invalid endpoint. Expected /versions', 54 + }) 55 + } 56 + 57 + const segments = pkgParamSegments.slice(0, -1) 58 + 59 + const { rawPackageName } = parsePackageParams(segments) 60 + 61 + if (!rawPackageName) { 62 + throw createError({ 63 + statusCode: 404, 64 + message: 'Package name is required', 65 + }) 66 + } 67 + 68 + try { 69 + const query = getQuery(event) 70 + const parsed = v.parse(QuerySchema, query) 71 + const mode = parsed.mode 72 + const filterThreshold = parsed.filterThreshold ?? 1 73 + const filterOldVersionsBool = parsed.filterOldVersions === 'true' 74 + 75 + const url = `https://api.npmjs.org/versions/${rawPackageName}/last-week` 76 + const npmResponse = await fetch(url) 77 + 78 + if (!npmResponse.ok) { 79 + if (npmResponse.status === 404) { 80 + throw createError({ 81 + statusCode: 404, 82 + message: 'Package not found', 83 + }) 84 + } 85 + throw createError({ 86 + statusCode: 502, 87 + message: 'Failed to fetch version download data from npm API', 88 + }) 89 + } 90 + 91 + const data: NpmVersionDownloadsResponse = await npmResponse.json() 92 + 93 + let groups = groupVersionDownloads(data.downloads, mode) 94 + 95 + if (filterThreshold > 0) { 96 + groups = groups.filter(group => group.percentage >= filterThreshold) 97 + } 98 + 99 + const totalDownloads = Object.values(data.downloads).reduce((sum, count) => sum + count, 0) 100 + 101 + const apiResponse: VersionDistributionResponse = { 102 + package: rawPackageName, 103 + mode, 104 + totalDownloads, 105 + groups, 106 + timestamp: new Date().toISOString(), 107 + } 108 + 109 + if (filterOldVersionsBool) { 110 + try { 111 + const oneYearAgo = new Date() 112 + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1) 113 + const afterDate = oneYearAgo.toISOString() 114 + 115 + // Decode package name in case it's URL-encoded (e.g., %40prisma%2Fclient -> @prisma/client) 116 + const decodedPackageName = decodeURIComponent(rawPackageName) 117 + 118 + // Fetch directly from npm-fast-meta HTTP API 119 + const fastMetaUrl = `https://npm.antfu.dev/versions/${encodeURIComponent(decodedPackageName)}?after=${encodeURIComponent(afterDate)}` 120 + const fastMetaResponse = await fetch(fastMetaUrl) 121 + 122 + if (!fastMetaResponse.ok) { 123 + throw new Error(`npm-fast-meta returned ${fastMetaResponse.status}`) 124 + } 125 + 126 + const versionData = (await fastMetaResponse.json()) as { versions: string[] } 127 + apiResponse.recentVersions = versionData.versions 128 + } catch { 129 + // Graceful degradation - don't fail entire request if npm-fast-meta fails 130 + } 131 + } 132 + 133 + return apiResponse 134 + } catch (error: unknown) { 135 + handleApiError(error, { 136 + statusCode: 502, 137 + message: 'Failed to fetch version download distribution', 138 + }) 139 + } 140 + }, 141 + { 142 + maxAge: CACHE_MAX_AGE_ONE_HOUR, 143 + swr: true, 144 + getKey: event => { 145 + const slug = getRouterParam(event, 'slug') ?? '' 146 + const query = getQuery(event) 147 + // Use ohash to create deterministic cache key from query params 148 + // This ensures different param combinations = different cache entries 149 + return `version-downloads:v5:${slug}:${hash(query)}` 150 + }, 151 + }, 152 + )
+169
server/utils/version-downloads.ts
··· 1 + import semver from 'semver' 2 + import type { 3 + VersionDownloadPoint, 4 + VersionGroupDownloads, 5 + VersionGroupingMode, 6 + } from '#shared/types' 7 + 8 + /** 9 + * Intermediate data structure for version processing 10 + */ 11 + interface ProcessedVersion { 12 + version: string 13 + downloads: number 14 + major: number 15 + minor: number 16 + parsed: semver.SemVer 17 + } 18 + 19 + /** 20 + * Filter out versions below a usage threshold 21 + * @param versions Array of version download points 22 + * @param thresholdPercent Minimum percentage to include (default: 0.1%) 23 + * @returns Filtered array of versions 24 + */ 25 + export function filterLowUsageVersions( 26 + versions: VersionDownloadPoint[], 27 + thresholdPercent: number = 0.1, 28 + ): VersionDownloadPoint[] { 29 + return versions.filter(v => v.percentage >= thresholdPercent) 30 + } 31 + 32 + /** 33 + * Parse and validate version strings, calculating total downloads 34 + * @param rawDownloads Raw download data from npm API 35 + * @returns Array of processed versions with parsed semver data 36 + */ 37 + function parseVersions(rawDownloads: Record<string, number>): ProcessedVersion[] { 38 + const processed: ProcessedVersion[] = [] 39 + 40 + for (const [version, downloads] of Object.entries(rawDownloads)) { 41 + const parsed = semver.parse(version) 42 + if (!parsed) continue 43 + 44 + processed.push({ 45 + version, 46 + downloads, 47 + major: parsed.major, 48 + minor: parsed.minor, 49 + parsed, 50 + }) 51 + } 52 + 53 + processed.sort((a, b) => semver.rcompare(a.version, b.version)) 54 + 55 + return processed 56 + } 57 + 58 + /** 59 + * Calculate percentage for each version 60 + * @param versions Processed versions 61 + * @param totalDownloads Total download count 62 + * @returns Array of version download points with percentages 63 + */ 64 + function addPercentages( 65 + versions: ProcessedVersion[], 66 + totalDownloads: number, 67 + ): VersionDownloadPoint[] { 68 + return versions.map(v => ({ 69 + version: v.version, 70 + downloads: v.downloads, 71 + percentage: totalDownloads > 0 ? (v.downloads / totalDownloads) * 100 : 0, 72 + })) 73 + } 74 + 75 + /** 76 + * Group versions by major version (e.g., 1.x, 2.x) 77 + * @param rawDownloads Raw download data from npm API 78 + * @returns Array of version groups sorted by downloads descending 79 + */ 80 + export function groupByMajor(rawDownloads: Record<string, number>): VersionGroupDownloads[] { 81 + const processed = parseVersions(rawDownloads) 82 + const totalDownloads = processed.reduce((sum, v) => sum + v.downloads, 0) 83 + 84 + const groups = new Map<number, ProcessedVersion[]>() 85 + for (const version of processed) { 86 + const existing = groups.get(version.major) || [] 87 + existing.push(version) 88 + groups.set(version.major, existing) 89 + } 90 + 91 + const result: VersionGroupDownloads[] = [] 92 + for (const [major, versions] of groups.entries()) { 93 + const groupDownloads = versions.reduce((sum, v) => sum + v.downloads, 0) 94 + const percentage = totalDownloads > 0 ? (groupDownloads / totalDownloads) * 100 : 0 95 + 96 + result.push({ 97 + groupKey: `${major}.x`, 98 + label: `v${major}.x`, 99 + downloads: groupDownloads, 100 + percentage, 101 + versions: addPercentages(versions, totalDownloads), 102 + }) 103 + } 104 + 105 + result.sort((a, b) => b.downloads - a.downloads) 106 + 107 + return result 108 + } 109 + 110 + /** 111 + * Group versions by major.minor (e.g., 1.2.x, 1.3.x) 112 + * Special handling for 0.x versions - treat them as separate majors 113 + * @param rawDownloads Raw download data from npm API 114 + * @returns Array of version groups sorted by downloads descending 115 + */ 116 + export function groupByMinor(rawDownloads: Record<string, number>): VersionGroupDownloads[] { 117 + const processed = parseVersions(rawDownloads) 118 + const totalDownloads = processed.reduce((sum, v) => sum + v.downloads, 0) 119 + 120 + // Group by major.minor 121 + const groups = new Map<string, ProcessedVersion[]>() 122 + for (const version of processed) { 123 + // For 0.x versions, treat each minor as significant (0.9.x, 0.10.x are different) 124 + // For 1.x+, group by major.minor normally 125 + const groupKey = `${version.major}.${version.minor}` 126 + const existing = groups.get(groupKey) || [] 127 + existing.push(version) 128 + groups.set(groupKey, existing) 129 + } 130 + 131 + // Convert to VersionGroupDownloads 132 + const result: VersionGroupDownloads[] = [] 133 + for (const [groupKey, versions] of groups.entries()) { 134 + const groupDownloads = versions.reduce((sum, v) => sum + v.downloads, 0) 135 + const percentage = totalDownloads > 0 ? (groupDownloads / totalDownloads) * 100 : 0 136 + 137 + result.push({ 138 + groupKey: `${groupKey}.x`, 139 + label: `v${groupKey}.x`, 140 + downloads: groupDownloads, 141 + percentage, 142 + versions: addPercentages(versions, totalDownloads), 143 + }) 144 + } 145 + 146 + result.sort((a, b) => b.downloads - a.downloads) 147 + 148 + return result 149 + } 150 + 151 + /** 152 + * Group versions by the specified mode 153 + * @param rawDownloads Raw download data from npm API 154 + * @param mode Grouping mode ('major' or 'minor') 155 + * @returns Array of version groups sorted by downloads descending 156 + */ 157 + export function groupVersionDownloads( 158 + rawDownloads: Record<string, number>, 159 + mode: VersionGroupingMode, 160 + ): VersionGroupDownloads[] { 161 + switch (mode) { 162 + case 'major': 163 + return groupByMajor(rawDownloads) 164 + case 'minor': 165 + return groupByMinor(rawDownloads) 166 + default: 167 + throw new Error(`Invalid grouping mode: ${mode}`) 168 + } 169 + }
+1
shared/types/index.ts
··· 8 8 export * from './i18n-status' 9 9 export * from './comparison' 10 10 export * from './skills' 11 + export * from './version-downloads'
+60
shared/types/version-downloads.ts
··· 1 + /** 2 + * Version Downloads Distribution Types 3 + * Types for version download statistics and grouping. 4 + * 5 + * These types support fetching per-version download counts from npm API 6 + * and grouping them by major/minor versions for distribution analysis. 7 + */ 8 + 9 + /** 10 + * Download data for a single package version 11 + */ 12 + export interface VersionDownloadPoint { 13 + /** Semantic version string (e.g., "1.2.3") */ 14 + version: string 15 + /** Download count for this version */ 16 + downloads: number 17 + /** Percentage of total downloads (0-100) */ 18 + percentage: number 19 + } 20 + 21 + /** 22 + * Aggregated download data for a version group (major or minor) 23 + */ 24 + export interface VersionGroupDownloads { 25 + /** Group identifier (e.g., "1.x" for major, "1.2.x" for minor) */ 26 + groupKey: string 27 + /** Human-readable label (e.g., "v1.x", "v1.2.x") */ 28 + label: string 29 + /** Total downloads for all versions in this group */ 30 + downloads: number 31 + /** Percentage of total downloads (0-100) */ 32 + percentage: number 33 + /** Individual versions in this group */ 34 + versions: VersionDownloadPoint[] 35 + } 36 + 37 + /** 38 + * Mode for grouping versions 39 + * - 'major': Group by major version (1.x, 2.x) 40 + * - 'minor': Group by minor version (1.2.x, 1.3.x) 41 + */ 42 + export type VersionGroupingMode = 'major' | 'minor' 43 + 44 + /** 45 + * API response for version download distribution 46 + */ 47 + export interface VersionDistributionResponse { 48 + /** Package name */ 49 + package: string 50 + /** Grouping mode used */ 51 + mode: VersionGroupingMode 52 + /** Total downloads across all versions */ 53 + totalDownloads: number 54 + /** Grouped version data */ 55 + groups: VersionGroupDownloads[] 56 + /** ISO 8601 timestamp when data was fetched */ 57 + timestamp: string 58 + /** List of version strings published within the last year (only present when filterOldVersions=true) */ 59 + recentVersions?: string[] 60 + }
+2 -2
test/nuxt/a11y.spec.ts
··· 594 594 describe('PackageChartModal', () => { 595 595 it('should have no accessibility violations when closed', async () => { 596 596 const component = await mountSuspended(PackageChartModal, { 597 - props: { open: false }, 598 - slots: { title: 'Downloads', default: '<div>Chart content</div>' }, 597 + props: { open: false, title: 'Downloads' }, 598 + slots: { default: '<div>Chart content</div>' }, 599 599 }) 600 600 const results = await runAxe(component) 601 601 expect(results.violations).toEqual([])
+2
test/unit/a11y-component-coverage.spec.ts
··· 40 40 'Settings/TranslationHelper.vue': 'i18n helper component - requires specific locale status data', 41 41 'Package/WeeklyDownloadStats.vue': 42 42 'Uses vue-data-ui VueUiSparkline - has DOM measurement issues in test environment', 43 + 'Package/VersionDistribution.vue': 44 + 'Uses vue-data-ui VueUiXy - has DOM measurement issues in test environment', 43 45 'UserCombobox.vue': 'Unused component - intended for future admin features', 44 46 'SkeletonBlock.vue': 'Already covered indirectly via other component tests', 45 47 'SkeletonInline.vue': 'Already covered indirectly via other component tests',