Skip to content
Open
722 changes: 588 additions & 134 deletions app/components/Package/TimelineChart.vue

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion app/pages/package-timeline/[[org]]/[packageName].vue
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<string, { totalSize: number; dependencyCount: number }>())
const sizeCache = shallowReactive(new Map<string, TimelineSizeCacheValue>())
const sizeFetchesInFlight = ref(0)
const sizesLoading = computed(() => sizeFetchesInFlight.value > 0)

Expand All @@ -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 {
Expand Down
235 changes: 233 additions & 2 deletions app/utils/charts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type {
VueUiXyConfig,
VueUiXyDatasetBarItem,
VueUiXyDatasetLineItem,
VueUiStackbarConfig,
VueUiStackbarFormattedDatasetItem,
} from 'vue-data-ui'
import type { ChartTimeGranularity } from '~/types/chart'

Expand Down Expand Up @@ -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 & {
Expand All @@ -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<void>
$t: TrendTranslateFunction
numberFormatter: (value: number) => string
}

export type TimelineStackbarConfig = VueUiStackbarConfig & {
packageName: string
versions: string[]
copy: (text: string) => Promise<void>
$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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<VueUiStackbarFormattedDatasetItem[], TimelineStackbarConfig>) {
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<VueUiStackbarFormattedDatasetItem[], TimelineStackbarConfig>) {
const altText = createAltTextForTimelineStackbar({ dataset, config })
await config.copy(altText)
}

export function sanitise(value: string) {
return value
.replace(/^@/, '')
Expand Down Expand Up @@ -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<T extends string | boolean>(props: unknown, key: string): T | undefined {
if (!props || typeof props !== 'object' || !(key in props)) return undefined

return (props as Record<string, T>)[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<keyof typeof annotatorDrawIcons>(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<boolean>(props, 'isAnnotator') ? 'i-lucide:pen-off' : 'i-lucide:pen'
}
}

export function getAnnotatorStyle(slotName: AnnotatorSlotName, props?: unknown) {
return {
color: slotName === 'annotator-action-color' ? getSlotProp<string>(props, 'color') : undefined,
pointerEvents:
slotName === 'annotator-action-color' || slotName === 'annotator-action-draw'
? undefined
: ('none' as const),
}
}
9 changes: 8 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,14 +637,20 @@
"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",
"ordered_versions": "stable only",
"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}."
}
}
},
Expand Down Expand Up @@ -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.",
Expand Down
21 changes: 21 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1915,6 +1915,12 @@
"tab_aria_label": {
"type": "string"
},
"dependency_size": {
"type": "string"
},
"other_dependencies": {
"type": "string"
},
"base_scale": {
"type": "string"
},
Expand All @@ -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
Expand Down Expand Up @@ -2224,6 +2242,9 @@
"watermark": {
"type": "string"
},
"watermark_top": {
"type": "string"
},
"analysis": {
"type": "string"
},
Expand Down
1 change: 1 addition & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading
Loading