diff --git a/app/components/Package/TimelineChart.vue b/app/components/Package/TimelineChart.vue index bd7f10c82e..5ac17f9caf 100644 --- a/app/components/Package/TimelineChart.vue +++ b/app/components/Package/TimelineChart.vue @@ -1,5 +1,4 @@ - + - - - - {{ $t('package.stats.install_size') }} - - - {{ $t('compare.dependencies') }} - - - + + + {{ $t('package.timeline.chart.tab_aria_label') }} + + + + + {{ tab.label }} + + + + + + + + {{ tab.label }} + + + + + - - + + + + { /> - - - - - - - + - - - - - - - - - - - - - - - - + { + + + + + + + + + + + + + + + + + + {{ timeLabel }} + + + + + + + + + {{ point.name }} + + + + {{ bytesFormatter.format(point.size) }} + + + ({{ point.delta > 0 ? '+' : '-' + }}{{ bytesFormatter.format(Math.abs(point.delta)) }}) + + + + + + + + + + + + + CSV + + + PNG + + + SVG + + + + + + + + + + + @@ -918,10 +1358,16 @@ const indexSelection = computed(() => { transition: none !important; } +:deep(.vue-ui-stackbar rect) { + animation: none !important; + transition: all 0.3s var(--super-ease-out) !important; +} + @media (prefers-reduced-motion: reduce) { :deep(.vue-data-ui-component .serie_line_0 path), .svg-element-transition, - :deep(.vdui-shape-circle) { + :deep(.vdui-shape-circle), + :deep(.vue-ui-stackbar rect) { transition: none !important; } } @@ -960,4 +1406,12 @@ const indexSelection = computed(() => { .animate-indeterminate { animation: indeterminate 1.5s ease-in-out infinite; } + +.loaded :deep(.vue-data-ui-component .serie_line_0 path), +.loaded .svg-element-transition, +.loaded :deep(.vdui-shape-circle), +.loaded :deep(.vue-ui-stackbar rect) { + transition: none !important; + animation: none !important; +} diff --git a/app/pages/package-timeline/[[org]]/[packageName].vue b/app/pages/package-timeline/[[org]]/[packageName].vue index 09277d3407..bf4baca70c 100644 --- a/app/pages/package-timeline/[[org]]/[packageName].vue +++ b/app/pages/package-timeline/[[org]]/[packageName].vue @@ -7,6 +7,7 @@ import type { SubEvent, } from '~~/server/api/registry/timeline/[...pkg].get' import type { TimelineSizeResponse } from '~~/server/api/registry/timeline/sizes/[...pkg].get' +import type { TimelineSizeCacheValue } from '~/utils/charts' definePageMeta({ name: 'timeline', @@ -126,7 +127,7 @@ const SIZE_INCREASE_THRESHOLD = 0.25 const DEP_INCREASE_THRESHOLD = 5 const NO_LICENSE_VALUES = new Set(['', 'UNLICENSED']) -const sizeCache = shallowReactive(new Map()) +const sizeCache = shallowReactive(new Map()) const sizeFetchesInFlight = ref(0) const sizesLoading = computed(() => sizeFetchesInFlight.value > 0) @@ -148,6 +149,8 @@ async function fetchSizes(offset: number) { sizeCache.set(`${requestedPackage}@${entry.version}`, { totalSize: entry.totalSize, dependencyCount: entry.dependencyCount, + selfSize: entry.selfSize, + dependencies: entry.dependencies, }) } } catch { diff --git a/app/utils/charts.ts b/app/utils/charts.ts index 6dcc781292..2d8965c59a 100644 --- a/app/utils/charts.ts +++ b/app/utils/charts.ts @@ -7,6 +7,8 @@ import type { VueUiXyConfig, VueUiXyDatasetBarItem, VueUiXyDatasetLineItem, + VueUiStackbarConfig, + VueUiStackbarFormattedDatasetItem, } from 'vue-data-ui' import type { ChartTimeGranularity } from '~/types/chart' @@ -458,9 +460,16 @@ export type FacetBarChartConfig = VueUiHorizontalBarConfig & { $t: TrendTranslateFunction } +export type TimelineSizeDependencyBreakdown = { + name: string + size: number +} + export type TimelineSizeCacheValue = { totalSize: number dependencyCount: number + selfSize: number + dependencies: TimelineSizeDependencyBreakdown[] } export type ConvertedTimelineSizeCacheEntry = TimelineSizeCacheValue & { @@ -481,14 +490,48 @@ export type EnrichedTimelineSizeCacheEntry = ConvertedTimelineSizeCacheEntry & { hasNegative: boolean } +export type TimelineChartMetric = 'totalSize' | 'dependencyCount' | 'dependencySize' + export type TimelineChartConfig = VueUiXyConfig & { - metric: 'totalSize' | 'dependencyCount' + metric: TimelineChartMetric packageName: string copy: (text: string) => Promise $t: TrendTranslateFunction numberFormatter: (value: number) => string } +export type TimelineStackbarConfig = VueUiStackbarConfig & { + packageName: string + versions: string[] + copy: (text: string) => Promise + $t: TrendTranslateFunction + numberFormatter: (value: number) => string + percentageFormatter?: (value: number) => string + maxSegments?: number +} + +type TimelineStackbarSegmentAnalysis = { + name: string + firstValue: number + lastValue: number + delta: number + lastShare: number + maxValue: number + maxVersion: string +} + +function getTimelineStackbarTotalAt( + dataset: VueUiStackbarFormattedDatasetItem[], + index: number, +): number { + return sum(dataset.map(item => item.series[index] ?? 0)) +} + +function formatTimelineStackbarPercentage(config: TimelineStackbarConfig, ratio: number): string { + const percentage = Math.round(ratio * 100) + return config.percentageFormatter?.(percentage) ?? `${percentage}%` +} + // Used for TrendsChart.vue export function createAltTextForTrendLineChart({ dataset, @@ -743,7 +786,7 @@ export async function copyAltTextForCompareScatterChart({ await config.copy(altText) } -// Used for TimelineChart.vue +// Used for TimelineChart.vue (total size and dependency count line charts) export function createAltTextForTimelineChart({ dataset, config, @@ -805,6 +848,127 @@ export async function copyAltTextForTimelineChart({ await config.copy(altText) } +// Used for TimelineChart.vue (dependency size stackbar chart) +export function createAltTextForTimelineStackbar({ + dataset, + config, +}: AltCopyArgs) { + if (!dataset?.length) return '' + + const seriesLength = Math.max(config.versions.length, ...dataset.map(item => item.series.length)) + + if (seriesLength === 0) return '' + + const firstIndex = 0 + const lastIndex = seriesLength - 1 + const firstVersion = config.versions[firstIndex] ?? String(firstIndex + 1) + const lastVersion = config.versions[lastIndex] ?? String(lastIndex + 1) + + const firstTotal = getTimelineStackbarTotalAt(dataset, firstIndex) + const lastTotal = getTimelineStackbarTotalAt(dataset, lastIndex) + const baseline = firstTotal + const current = lastTotal + const overall_progress_percentage = + baseline > 0 ? Math.round(((current - baseline) / baseline) * 100) : 0 + + const segments: TimelineStackbarSegmentAnalysis[] = dataset + .map(item => { + const firstValue = item.series[firstIndex] ?? 0 + const lastValue = item.series[lastIndex] ?? 0 + const max = item.series.reduce( + (currentMax, value, index) => { + if (value > currentMax.value) { + return { value, index } + } + + return currentMax + }, + { value: 0, index: 0 }, + ) + + return { + name: item.name, + firstValue, + lastValue, + delta: lastValue - firstValue, + lastShare: lastTotal > 0 ? lastValue / lastTotal : 0, + maxValue: max.value, + maxVersion: config.versions[max.index] ?? String(max.index + 1), + } + }) + .filter(segment => segment.firstValue > 0 || segment.lastValue > 0 || segment.maxValue > 0) + + const maxSegments = config.maxSegments ?? 5 + + const topSegments = segments + .filter(segment => segment.lastValue > 0) + .toSorted((a, b) => b.lastValue - a.lastValue) + .slice(0, maxSegments) + + const largestIncrease = segments + .filter(segment => segment.delta > 0) + .toSorted((a, b) => b.delta - a.delta)[0] + + const largestDecrease = segments + .filter(segment => segment.delta < 0) + .toSorted((a, b) => a.delta - b.delta)[0] + + const top_segments = topSegments + .map(segment => + config.$t('package.timeline.chart.copy_alt.stackbar_segment_share', { + segment: segment.name, + value: config.numberFormatter(segment.lastValue), + percentage: formatTimelineStackbarPercentage(config, segment.lastShare), + }), + ) + .join(', ') + + const key_changes = [ + topSegments.length + ? config.$t('package.timeline.chart.copy_alt.stackbar_top_segments', { + version: lastVersion, + segments: top_segments, + }) + : '', + largestIncrease + ? config.$t('package.timeline.chart.copy_alt.stackbar_largest_increase', { + segment: largestIncrease.name, + delta: config.numberFormatter(largestIncrease.delta), + }) + : '', + largestDecrease + ? config.$t('package.timeline.chart.copy_alt.stackbar_largest_decrease', { + segment: largestDecrease.name, + delta: config.numberFormatter(Math.abs(largestDecrease.delta)), + }) + : '', + ] + .filter(Boolean) + .join(' ') + + const altText = config.$t('package.timeline.chart.copy_alt.general_description', { + metric: config.$t('package.timeline.chart.dependency_size').toLocaleLowerCase(), + package: config.packageName, + first: firstVersion, + last: lastVersion, + first_value: config.numberFormatter(firstTotal), + last_value: config.numberFormatter(lastTotal), + overall_progress_percentage, + key_changes, + watermark: config.$t('package.trends.copy_alt.watermark_top'), + }) + + return altText +} + +export async function copyAltTextForTimelineStackbar({ + dataset, + config, +}: AltCopyArgs) { + const altText = createAltTextForTimelineStackbar({ dataset, config }) + await config.copy(altText) +} + export function sanitise(value: string) { return value .replace(/^@/, '') @@ -891,3 +1055,70 @@ export const CHART_PATTERN_CONFIG = { minSize: 16, maxSize: 24, } + +/** + * Chart annotator slots + */ + +type AnnotatorSlotName = + | 'annotator-action-close' + | 'annotator-action-color' + | 'annotator-action-draw' + | 'annotator-action-undo' + | 'annotator-action-redo' + | 'annotator-action-delete' + | 'optionAnnotator' + +export const CHART_ANNOTATOR_SLOTS = [ + 'annotator-action-close', + 'annotator-action-color', + 'annotator-action-draw', + 'annotator-action-undo', + 'annotator-action-redo', + 'annotator-action-delete', + 'optionAnnotator', +] as const satisfies readonly AnnotatorSlotName[] + +const annotatorDrawIcons = { + arrow: 'i-lucide:move-up-right', + text: 'i-lucide:type', + line: 'i-lucide:pen-line', + draw: 'i-lucide:line-squiggle', +} as const + +function getSlotProp(props: unknown, key: string): T | undefined { + if (!props || typeof props !== 'object' || !(key in props)) return undefined + + return (props as Record)[key] +} + +export function getAnnotatorIcon(slotName: AnnotatorSlotName, props?: unknown) { + switch (slotName) { + case 'annotator-action-close': + return 'i-lucide:x' + case 'annotator-action-color': + return 'i-lucide:palette' + case 'annotator-action-draw': { + const mode = getSlotProp(props, 'mode') + return mode ? annotatorDrawIcons[mode] : null + } + case 'annotator-action-undo': + return 'i-lucide:undo-2' + case 'annotator-action-redo': + return 'i-lucide:redo-2' + case 'annotator-action-delete': + return 'i-lucide:trash' + case 'optionAnnotator': + return getSlotProp(props, 'isAnnotator') ? 'i-lucide:pen-off' : 'i-lucide:pen' + } +} + +export function getAnnotatorStyle(slotName: AnnotatorSlotName, props?: unknown) { + return { + color: slotName === 'annotator-action-color' ? getSlotProp(props, 'color') : undefined, + pointerEvents: + slotName === 'annotator-action-color' || slotName === 'annotator-action-draw' + ? undefined + : ('none' as const), + } +} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 34e9679053..03ba6050c0 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -637,6 +637,8 @@ "provenance_removed": "Provenance removed", "chart": { "tab_aria_label": "Metric selection", + "dependency_size": "Dependency Size", + "other_dependencies": "Other", "base_scale": "start y-axis at zero", "zoom": "zoom", "reset_minimap": "reset minimap", @@ -644,7 +646,11 @@ "copy_alt": { "key_changes": "Key changes: {version_events}.", "version_events": "version {version}: {events}", - "general_description": "Line chart showing the {metric} of the {package} package, from version {first} to {last}. The {metric} in version {first} is {first_value}, in version {last} is {last_value} ({overall_progress_percentage}% overall). {key_changes} {watermark}." + "general_description": "Line chart showing the {metric} of the {package} package, from version {first} to {last}. The {metric} in version {first} is {first_value}, in version {last} is {last_value} ({overall_progress_percentage}% overall). {key_changes} {watermark}.", + "stackbar_segment_share": "{segment}: {value} ({percentage})", + "stackbar_top_segments": "In {version}, the largest segments are {segments}.", + "stackbar_largest_increase": "The largest increase is {segment}, up {delta}.", + "stackbar_largest_decrease": "The largest decrease is {segment}, down {delta}." } } }, @@ -740,6 +746,7 @@ "trend_undefined": "undefined (insufficient data)", "button_label": "Copy alt text", "watermark": "At the bottom, a watermark reads \"./npmx a fast, modern browser for the npm registry\"", + "watermark_top": "At the top, a watermark reads \"./npmx a fast, modern browser for the npm registry\"", "analysis": "{package_name} starts at {start_value} and ends at {end_value}, showing a {trend} trend with a slope of {downloads_slope} downloads per time interval", "estimation": "The final value is an estimate based on partial data for the current period.", "estimations": "The final values are estimates based on partial data for the current period.", diff --git a/i18n/schema.json b/i18n/schema.json index 78c93bab56..7c7b635df8 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1915,6 +1915,12 @@ "tab_aria_label": { "type": "string" }, + "dependency_size": { + "type": "string" + }, + "other_dependencies": { + "type": "string" + }, "base_scale": { "type": "string" }, @@ -1938,6 +1944,18 @@ }, "general_description": { "type": "string" + }, + "stackbar_segment_share": { + "type": "string" + }, + "stackbar_top_segments": { + "type": "string" + }, + "stackbar_largest_increase": { + "type": "string" + }, + "stackbar_largest_decrease": { + "type": "string" } }, "additionalProperties": false @@ -2224,6 +2242,9 @@ "watermark": { "type": "string" }, + "watermark_top": { + "type": "string" + }, "analysis": { "type": "string" }, diff --git a/nuxt.config.ts b/nuxt.config.ts index a70b1f5313..1637717d48 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -425,6 +425,7 @@ export default defineNuxtConfig({ 'vue-data-ui/vue-ui-xy', 'vue-data-ui/vue-ui-scatter', 'vue-data-ui/vue-ui-horizontal-bar', + 'vue-data-ui/vue-ui-stackbar', 'virtua/vue', 'semver', 'validate-npm-package-name', diff --git a/package.json b/package.json index 68bc410270..75e8d8456e 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,7 @@ "vite-plugin-pwa": "1.3.0", "vite-plus": "0.1.20", "vue": "3.5.39", - "vue-data-ui": "3.22.0", + "vue-data-ui": "3.22.3", "vue-router": "5.0.4" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06bdd8375a..8ba32ceac6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,8 +256,8 @@ importers: specifier: 3.5.39 version: 3.5.39(typescript@6.0.2) vue-data-ui: - specifier: 3.22.0 - version: 3.22.0(vue@3.5.39) + specifier: 3.22.3 + version: 3.22.3(vue@3.5.39) vue-router: specifier: 5.0.4 version: 5.0.4(@vue/compiler-sfc@3.5.39)(vue@3.5.39) @@ -418,7 +418,7 @@ importers: version: 12.8.0 docus: specifier: 5.9.0 - version: 5.9.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@nuxt/schema@4.4.8)(@takumi-rs/wasm@1.0.9)(@tiptap/extensions@3.24.0)(@tiptap/y-tiptap@3.0.2)(@unhead/vue@2.1.15)(@upstash/redis@1.37.0)(@vue/compiler-dom@3.5.39)(better-sqlite3@12.8.0)(db0@0.3.4)(embla-carousel@8.6.0)(eslint@9.39.2)(focus-trap@8.0.0)(fontless@0.2.1)(h3@1.15.11)(ioredis@5.10.1)(magicast@0.5.3)(nitropack@2.13.4)(nuxt@4.4.8)(playwright-core@1.60.0)(react-dom@19.2.4)(react@19.2.4)(rollup@4.60.3)(sharp@0.34.5)(typescript@6.0.2)(unifont@0.7.4)(unstorage@1.17.5)(valibot@1.3.1)(vite@8.0.0)(vue-router@5.0.4)(vue@3.5.39)(yjs@13.6.29) + version: 5.9.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@nuxt/schema@4.4.8)(@takumi-rs/wasm@1.0.9)(@tiptap/extensions@3.24.0)(@tiptap/y-tiptap@3.0.2)(@unhead/vue@2.1.15)(@upstash/redis@1.37.0)(@vue/compiler-dom@3.5.39)(better-sqlite3@12.8.0)(db0@0.3.4)(embla-carousel@8.6.0)(eslint@9.39.2)(focus-trap@8.0.0)(fontless@0.2.1)(h3@2.0.1-rc.20)(ioredis@5.10.1)(magicast@0.5.3)(nitropack@2.13.4)(nuxt@4.4.8)(playwright-core@1.60.0)(react-dom@19.2.4)(react@19.2.4)(rollup@4.60.3)(sharp@0.34.5)(typescript@6.0.2)(unifont@0.7.4)(unstorage@1.17.5)(valibot@1.3.1)(vite@8.0.0)(vue-router@5.0.4)(vue@3.5.39)(yjs@13.6.29) nuxt: specifier: 4.4.8 version: 4.4.8(@babel/plugin-syntax-jsx@7.28.6)(@babel/plugin-syntax-typescript@7.28.6)(@parcel/watcher@2.5.6)(@types/node@24.12.0)(@upstash/redis@1.37.0)(@vue/compiler-sfc@3.5.39)(better-sqlite3@12.8.0)(cac@6.7.14)(db0@0.3.4)(esbuild@0.27.3)(eslint@9.39.2)(ioredis@5.10.1)(magicast@0.5.3)(optionator@0.9.4)(oxlint@1.61.0)(rollup-plugin-visualizer@7.0.1)(rollup@4.60.3)(terser@5.46.0)(typescript@6.0.2)(vite@8.0.0)(vue-tsc@3.2.6)(yaml@2.9.0) @@ -11257,8 +11257,8 @@ packages: vue-component-type-helpers@3.3.5: resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==} - vue-data-ui@3.22.0: - resolution: {integrity: sha512-FwEv+dtzrIny8jYR1qQ8Ye6IYMA7MDCy+86LQAldV5n/iWF1Ebfx6LI8LHfOBXL7mMZLZ0kggeukGo9y1Ssuyw==} + vue-data-ui@3.22.3: + resolution: {integrity: sha512-CfH4X/fASIaeKJDUdLE19UstTbTXCB9mm6U2Ig95MGPekOLo4ZvC/9OfUcUEe7PwzGyu3a9861hL8EvM4IVqpA==} peerDependencies: jspdf: '>=3.0.1' vue: '>=3.3.0' @@ -14598,11 +14598,11 @@ snapshots: - uploadthing - vue - '@nuxtjs/mcp-toolkit@0.13.4(h3@1.15.11)(magicast@0.5.3)(zod@4.3.6)': + '@nuxtjs/mcp-toolkit@0.13.4(h3@2.0.1-rc.20)(magicast@0.5.3)(zod@4.3.6)': dependencies: '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) '@nuxt/kit': 4.4.8(magicast@0.5.3) - h3: 1.15.11 + h3: 2.0.1-rc.20 tinyglobby: 0.2.17 zod: 4.3.6 transitivePeerDependencies: @@ -18283,7 +18283,7 @@ snapshots: doctypes@1.1.0: {} - docus@5.9.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@nuxt/schema@4.4.8)(@takumi-rs/wasm@1.0.9)(@tiptap/extensions@3.24.0)(@tiptap/y-tiptap@3.0.2)(@unhead/vue@2.1.15)(@upstash/redis@1.37.0)(@vue/compiler-dom@3.5.39)(better-sqlite3@12.8.0)(db0@0.3.4)(embla-carousel@8.6.0)(eslint@9.39.2)(focus-trap@8.0.0)(fontless@0.2.1)(h3@1.15.11)(ioredis@5.10.1)(magicast@0.5.3)(nitropack@2.13.4)(nuxt@4.4.8)(playwright-core@1.60.0)(react-dom@19.2.4)(react@19.2.4)(rollup@4.60.3)(sharp@0.34.5)(typescript@6.0.2)(unifont@0.7.4)(unstorage@1.17.5)(valibot@1.3.1)(vite@8.0.0)(vue-router@5.0.4)(vue@3.5.39)(yjs@13.6.29): + docus@5.9.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@nuxt/schema@4.4.8)(@takumi-rs/wasm@1.0.9)(@tiptap/extensions@3.24.0)(@tiptap/y-tiptap@3.0.2)(@unhead/vue@2.1.15)(@upstash/redis@1.37.0)(@vue/compiler-dom@3.5.39)(better-sqlite3@12.8.0)(db0@0.3.4)(embla-carousel@8.6.0)(eslint@9.39.2)(focus-trap@8.0.0)(fontless@0.2.1)(h3@2.0.1-rc.20)(ioredis@5.10.1)(magicast@0.5.3)(nitropack@2.13.4)(nuxt@4.4.8)(playwright-core@1.60.0)(react-dom@19.2.4)(react@19.2.4)(rollup@4.60.3)(sharp@0.34.5)(typescript@6.0.2)(unifont@0.7.4)(unstorage@1.17.5)(valibot@1.3.1)(vite@8.0.0)(vue-router@5.0.4)(vue@3.5.39)(yjs@13.6.29): dependencies: '@ai-sdk/gateway': 3.0.101(zod@4.3.6) '@ai-sdk/mcp': 1.0.36(zod@4.3.6) @@ -18296,7 +18296,7 @@ snapshots: '@nuxt/kit': 4.4.8(magicast@0.5.3) '@nuxt/ui': 4.6.1(@nuxt/content@3.12.0)(@tiptap/extensions@3.24.0)(@tiptap/y-tiptap@3.0.2)(@upstash/redis@1.37.0)(db0@0.3.4)(embla-carousel@8.6.0)(focus-trap@8.0.0)(ioredis@5.10.1)(magicast@0.5.3)(react-dom@19.2.4)(react@19.2.4)(tailwindcss@4.2.2)(typescript@6.0.2)(valibot@1.3.1)(vite@8.0.0)(vue-router@5.0.4)(vue@3.5.39)(yjs@13.6.29)(zod@4.3.6) '@nuxtjs/i18n': 10.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@upstash/redis@1.37.0)(@vue/compiler-dom@3.5.39)(db0@0.3.4)(eslint@9.39.2)(ioredis@5.10.1)(magicast@0.5.3)(rollup@4.60.3)(typescript@6.0.2)(vue@3.5.39) - '@nuxtjs/mcp-toolkit': 0.13.4(h3@1.15.11)(magicast@0.5.3)(zod@4.3.6) + '@nuxtjs/mcp-toolkit': 0.13.4(h3@2.0.1-rc.20)(magicast@0.5.3)(zod@4.3.6) '@nuxtjs/mdc': 0.21.1(magicast@0.5.3) '@nuxtjs/robots': 6.0.9(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.8)(vite@8.0.0)(vue@3.5.39)(zod@4.3.6) '@shikijs/core': 4.1.0 @@ -24317,7 +24317,7 @@ snapshots: vue-component-type-helpers@3.3.5: {} - vue-data-ui@3.22.0(vue@3.5.39): + vue-data-ui@3.22.3(vue@3.5.39): dependencies: vue: 3.5.39(typescript@6.0.2) diff --git a/server/api/registry/timeline/sizes/[...pkg].get.ts b/server/api/registry/timeline/sizes/[...pkg].get.ts index f8c654c5a8..da99d688a0 100644 --- a/server/api/registry/timeline/sizes/[...pkg].get.ts +++ b/server/api/registry/timeline/sizes/[...pkg].get.ts @@ -2,10 +2,26 @@ import { getVersions } from 'fast-npm-meta' const DEFAULT_LIMIT = 25 +/** + * Max number of individual dependencies returned per version for the size + * breakdown. Dependencies are sorted by size, so the largest are kept and the + * long tail is dropped (the client folds the remainder into an "Other" segment). + */ +const MAX_BREAKDOWN_DEPENDENCIES = 30 + +export interface TimelineSizeDependency { + name: string + size: number +} + export interface TimelineSizeEntry { version: string totalSize: number dependencyCount: number + /** Unpacked size of the package itself (bytes) */ + selfSize: number + /** Largest individual dependencies by unpacked self size (deep, deduped) */ + dependencies: TimelineSizeDependency[] } export interface TimelineSizeResponse { @@ -60,6 +76,10 @@ export default defineCachedEventHandler( version: result.value.version, totalSize: result.value.totalSize, dependencyCount: result.value.dependencyCount, + selfSize: result.value.selfSize, + dependencies: result.value.dependencies + .slice(0, MAX_BREAKDOWN_DEPENDENCIES) + .map(dep => ({ name: dep.name, size: dep.size })), }) } } @@ -79,7 +99,7 @@ export default defineCachedEventHandler( const query = getQuery(event) const offset = Math.max(0, Number(query.offset) || 0) const limit = Math.max(1, Math.min(100, Number(query.limit) || DEFAULT_LIMIT)) - return `install-size-timeline:v1:${getRouterParam(event, 'pkg')}:${offset}:${limit}` + return `install-size-timeline:v2:${getRouterParam(event, 'pkg')}:${offset}:${limit}` }, }, ) diff --git a/test/unit/app/utils/charts.spec.ts b/test/unit/app/utils/charts.spec.ts index bd5ff80513..fca6ec6dc6 100644 --- a/test/unit/app/utils/charts.spec.ts +++ b/test/unit/app/utils/charts.spec.ts @@ -13,6 +13,8 @@ import { copyAltTextForVersionsBarChart, createAltTextForTimelineChart, copyAltTextForTimelineChart, + createAltTextForTimelineStackbar, + copyAltTextForTimelineStackbar, sanitise, insertLineBreaks, applyEllipsis, @@ -21,9 +23,10 @@ import { type VersionsBarConfig, type VersionsBarDataset, type TimelineChartConfig, + type TimelineStackbarConfig, type EnrichedTimelineSizeCacheEntry, } from '~/utils/charts' -import type { AltCopyArgs } from 'vue-data-ui' +import type { AltCopyArgs, VueUiStackbarFormattedDatasetItem } from 'vue-data-ui' type TranslateCall = { key: string | number; named?: Record } @@ -51,6 +54,21 @@ function createTimelineConfig(overrides: Partial = {}): Tim return { ...config, ...overrides } } +function createTimelineStackbarConfig( + overrides: Partial = {}, +): TimelineStackbarConfig { + const { translate } = createTranslateMock() + const config: TimelineStackbarConfig = { + numberFormatter: (value: number) => `nf${value}`, + packageName: 'nuxt', + versions: ['4.0.0', '4.0.1', '4.1.0'], + copy: vi.fn(async () => undefined), + $t: translate, + } as unknown as TimelineStackbarConfig + + return { ...config, ...overrides } +} + function createTrendLineConfig(overrides: Partial = {}): TrendLineConfig { const { translate } = createTranslateMock() @@ -1277,6 +1295,228 @@ describe('copyAltTextForTimelineChart', () => { }) }) +const timelineStackbarDataset = [ + { + name: 'vue', + series: [100, 150, 200], + }, + { + name: 'vite', + series: [50, 80, 40], + }, + { + name: 'nitro', + series: [0, 20, 60], + }, + { + name: 'empty-package', + series: [0, 0, 0], + }, +] as unknown as VueUiStackbarFormattedDatasetItem[] + +describe('createAltTextForTimelineStackbar', () => { + it('returns empty string when dataset is null', () => { + const translateMock = createTranslateMock() + const config = createTimelineStackbarConfig({ $t: translateMock.translate }) + + const result = createAltTextForTimelineStackbar({ + dataset: null, + config, + } as unknown as AltCopyArgs) + + expect(result).toBe('') + expect(translateMock.calls).toHaveLength(0) + }) + + it('returns empty string when dataset is empty', () => { + const translateMock = createTranslateMock() + const config = createTimelineStackbarConfig({ $t: translateMock.translate }) + + const result = createAltTextForTimelineStackbar({ + dataset: [], + config, + } as AltCopyArgs) + + expect(result).toBe('') + expect(translateMock.calls).toHaveLength(0) + }) + + it('calls general_description with expected stackbar totals and version bounds', () => { + const translateMock = createTranslateMock() + const config = createTimelineStackbarConfig({ + $t: translateMock.translate, + versions: ['4.0.0', '4.0.1', '4.1.0'], + numberFormatter: (value: number) => `nf:${value}`, + }) + + const result = createAltTextForTimelineStackbar({ + dataset: timelineStackbarDataset, + config, + } as AltCopyArgs) + + expect(result).toBe('t:package.timeline.chart.copy_alt.general_description') + + const generalDescriptionCall = translateMock.calls.find( + call => call.key === 'package.timeline.chart.copy_alt.general_description', + ) + expect(generalDescriptionCall).toBeTruthy() + + expect(generalDescriptionCall?.named).toMatchObject({ + metric: 't:package.timeline.chart.dependency_size', + package: 'nuxt', + first: '4.0.0', + last: '4.1.0', + first_value: 'nf:150', + last_value: 'nf:300', + overall_progress_percentage: 100, + watermark: 't:package.trends.copy_alt.watermark_top', + }) + expect(generalDescriptionCall?.named?.key_changes).toBe( + [ + 't:package.timeline.chart.copy_alt.stackbar_top_segments', + 't:package.timeline.chart.copy_alt.stackbar_largest_increase', + 't:package.timeline.chart.copy_alt.stackbar_largest_decrease', + ].join(' '), + ) + }) + + it('describes top segments sorted by last value and limited by maxSegments', () => { + const translateMock = createTranslateMock() + const config = createTimelineStackbarConfig({ + $t: translateMock.translate, + maxSegments: 2, + numberFormatter: (value: number) => `nf:${value}`, + }) + + createAltTextForTimelineStackbar({ + dataset: timelineStackbarDataset, + config, + } as AltCopyArgs) + + const segmentShareCalls = translateMock.calls.filter( + call => call.key === 'package.timeline.chart.copy_alt.stackbar_segment_share', + ) + expect(segmentShareCalls).toHaveLength(2) + expect(segmentShareCalls[0]?.named).toMatchObject({ + segment: 'vue', + value: 'nf:200', + percentage: '67%', + }) + expect(segmentShareCalls[1]?.named).toMatchObject({ + segment: 'nitro', + value: 'nf:60', + percentage: '20%', + }) + + const topSegmentsCall = translateMock.calls.find( + call => call.key === 'package.timeline.chart.copy_alt.stackbar_top_segments', + ) + expect(topSegmentsCall?.named).toMatchObject({ + version: '4.1.0', + segments: [ + 't:package.timeline.chart.copy_alt.stackbar_segment_share', + 't:package.timeline.chart.copy_alt.stackbar_segment_share', + ].join(', '), + }) + }) + + it('describes the largest increase and decrease between first and last versions', () => { + const translateMock = createTranslateMock() + const config = createTimelineStackbarConfig({ + $t: translateMock.translate, + numberFormatter: (value: number) => `nf:${value}`, + }) + + createAltTextForTimelineStackbar({ + dataset: timelineStackbarDataset, + config, + } as AltCopyArgs) + + const largestIncreaseCall = translateMock.calls.find( + call => call.key === 'package.timeline.chart.copy_alt.stackbar_largest_increase', + ) + expect(largestIncreaseCall?.named).toMatchObject({ + segment: 'vue', + delta: 'nf:100', + }) + + const largestDecreaseCall = translateMock.calls.find( + call => call.key === 'package.timeline.chart.copy_alt.stackbar_largest_decrease', + ) + expect(largestDecreaseCall?.named).toMatchObject({ + segment: 'vite', + delta: 'nf:10', + }) + }) + + it('uses percentageFormatter when provided', () => { + const translateMock = createTranslateMock() + const percentageFormatter = vi.fn((value: number) => `pf:${value}`) + const config = createTimelineStackbarConfig({ + $t: translateMock.translate, + maxSegments: 1, + percentageFormatter, + }) + + createAltTextForTimelineStackbar({ + dataset: timelineStackbarDataset, + config, + } as AltCopyArgs) + + const segmentShareCall = translateMock.calls.find( + call => call.key === 'package.timeline.chart.copy_alt.stackbar_segment_share', + ) + + expect(percentageFormatter).toHaveBeenCalledWith(67) + expect(segmentShareCall?.named).toHaveProperty('percentage', 'pf:67') + }) + + it('falls back to numeric positions when version labels are missing', () => { + const translateMock = createTranslateMock() + const config = createTimelineStackbarConfig({ + $t: translateMock.translate, + versions: [], + }) + + createAltTextForTimelineStackbar({ + dataset: timelineStackbarDataset, + config, + } as AltCopyArgs) + + const generalDescriptionCall = translateMock.calls.find( + call => call.key === 'package.timeline.chart.copy_alt.general_description', + ) + expect(generalDescriptionCall?.named).toMatchObject({ + first: '1', + last: '3', + }) + + const topSegmentsCall = translateMock.calls.find( + call => call.key === 'package.timeline.chart.copy_alt.stackbar_top_segments', + ) + expect(topSegmentsCall?.named).toHaveProperty('version', '3') + }) +}) + +describe('copyAltTextForTimelineStackbar', () => { + it('forwards createAltTextForTimelineStackbar result to config.copy', async () => { + const copyMock = vi.fn(async () => undefined) + const config = createTimelineStackbarConfig({ copy: copyMock }) + const expected = createAltTextForTimelineStackbar({ + dataset: timelineStackbarDataset, + config, + }) + + await copyAltTextForTimelineStackbar({ + dataset: timelineStackbarDataset, + config, + } as AltCopyArgs) + + expect(copyMock).toHaveBeenCalledTimes(1) + expect(copyMock).toHaveBeenCalledWith(expected) + }) +}) + describe('sanitise', () => { it('returns the same string when no sanitisation is needed', () => { expect(sanitise('nuxt-package')).toBe('nuxt-package')