forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1<script setup lang="ts">
2import type { VueUiXyDatasetItem } from 'vue-data-ui'
3import { VueUiXy } from 'vue-data-ui/vue-ui-xy'
4import { useDebounceFn, useElementSize } from '@vueuse/core'
5import { useCssVariables } from '~/composables/useColors'
6import { OKLCH_NEUTRAL_FALLBACK, transparentizeOklch } from '~/utils/colors'
7import { getFrameworkColor, isListedFramework } from '~/utils/frameworks'
8import { drawNpmxLogoAndTaglineWatermark } from '~/composables/useChartWatermark'
9import type { RepoRef } from '#shared/utils/git-providers'
10import type {
11 ChartTimeGranularity,
12 DailyDataPoint,
13 DateRangeFields,
14 EvolutionData,
15 EvolutionOptions,
16 MonthlyDataPoint,
17 WeeklyDataPoint,
18 YearlyDataPoint,
19} from '~/types/chart'
20import { DATE_INPUT_MAX } from '~/utils/input'
21
22const props = withDefaults(
23 defineProps<{
24 // For single package downloads history
25 weeklyDownloads?: WeeklyDataPoint[]
26 inModal?: boolean
27
28 /**
29 * Backward compatible single package mode.
30 * Used when `weeklyDownloads` is provided.
31 */
32 packageName?: string
33
34 /**
35 * Multi-package mode.
36 * Used when `weeklyDownloads` is not provided.
37 */
38 packageNames?: string[]
39 repoRef?: RepoRef | null | undefined
40 createdIso?: string | null
41
42 /** When true, shows facet selector (e.g. Downloads / Likes). */
43 showFacetSelector?: boolean
44 permalink?: boolean
45 }>(),
46 {
47 permalink: false,
48 },
49)
50
51const { locale } = useI18n()
52const { accentColors, selectedAccentColor } = useAccentColor()
53const colorMode = useColorMode()
54const resolvedMode = shallowRef<'light' | 'dark'>('light')
55const rootEl = shallowRef<HTMLElement | null>(null)
56const isZoomed = shallowRef(false)
57
58function setIsZoom({ isZoom }: { isZoom: boolean }) {
59 isZoomed.value = isZoom
60}
61
62const { width } = useElementSize(rootEl)
63
64const compactNumberFormatter = useCompactNumberFormatter()
65
66onMounted(async () => {
67 rootEl.value = document.documentElement
68 resolvedMode.value = colorMode.value === 'dark' ? 'dark' : 'light'
69
70 initDateRangeFromWeekly()
71 initDateRangeForMultiPackageWeekly52()
72 initDateRangeFallbackClient()
73
74 await nextTick()
75 isMounted.value = true
76
77 loadMetric(selectedMetric.value)
78})
79
80const { colors } = useCssVariables(
81 [
82 '--bg',
83 '--fg',
84 '--bg-subtle',
85 '--bg-elevated',
86 '--fg-subtle',
87 '--fg-muted',
88 '--border',
89 '--border-subtle',
90 ],
91 {
92 element: rootEl,
93 watchHtmlAttributes: true,
94 watchResize: false,
95 },
96)
97
98watch(
99 () => colorMode.value,
100 value => {
101 resolvedMode.value = value === 'dark' ? 'dark' : 'light'
102 },
103 { flush: 'sync' },
104)
105
106const isDarkMode = computed(() => resolvedMode.value === 'dark')
107
108const accentColorValueById = computed<Record<string, string>>(() => {
109 const map: Record<string, string> = {}
110 for (const item of accentColors.value) {
111 map[item.id] = item.value
112 }
113 return map
114})
115
116const accent = computed(() => {
117 const id = selectedAccentColor.value
118 return id
119 ? (accentColorValueById.value[id] ?? colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK)
120 : (colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK)
121})
122
123const watermarkColors = computed(() => ({
124 fg: colors.value.fg ?? OKLCH_NEUTRAL_FALLBACK,
125 bg: colors.value.bg ?? OKLCH_NEUTRAL_FALLBACK,
126 fgSubtle: colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK,
127}))
128
129const mobileBreakpointWidth = 640
130const isMobile = computed(() => width.value > 0 && width.value < mobileBreakpointWidth)
131
132const DEFAULT_GRANULARITY: ChartTimeGranularity = 'weekly'
133
134function isRecord(value: unknown): value is Record<string, unknown> {
135 return typeof value === 'object' && value !== null
136}
137
138function isWeeklyDataset(data: unknown): data is WeeklyDataPoint[] {
139 return (
140 Array.isArray(data) &&
141 data.length > 0 &&
142 isRecord(data[0]) &&
143 'weekStart' in data[0] &&
144 'weekEnd' in data[0] &&
145 'value' in data[0]
146 )
147}
148function isDailyDataset(data: unknown): data is DailyDataPoint[] {
149 return (
150 Array.isArray(data) &&
151 data.length > 0 &&
152 isRecord(data[0]) &&
153 'day' in data[0] &&
154 'value' in data[0]
155 )
156}
157function isMonthlyDataset(data: unknown): data is MonthlyDataPoint[] {
158 return (
159 Array.isArray(data) &&
160 data.length > 0 &&
161 isRecord(data[0]) &&
162 'month' in data[0] &&
163 'value' in data[0]
164 )
165}
166function isYearlyDataset(data: unknown): data is YearlyDataPoint[] {
167 return (
168 Array.isArray(data) &&
169 data.length > 0 &&
170 isRecord(data[0]) &&
171 'year' in data[0] &&
172 'value' in data[0]
173 )
174}
175
176/**
177 * Formats a single evolution dataset into the structure expected by `VueUiXy`
178 * for single-series charts.
179 *
180 * The dataset is interpreted based on the selected time granularity:
181 * - **daily** → uses `timestamp`
182 * - **weekly** → uses `timestampEnd`
183 * - **monthly** → uses `timestamp`
184 * - **yearly** → uses `timestamp`
185 *
186 * Only datasets matching the expected shape for the given granularity are
187 * accepted. If the dataset does not match, an empty result is returned.
188 *
189 * The returned structure includes:
190 * - a single line-series dataset with a consistent color
191 * - a list of timestamps used as the x-axis values
192 *
193 * @param selectedGranularity - Active chart time granularity
194 * @param dataset - Raw evolution dataset to format
195 * @param seriesName - Display name for the resulting series
196 * @returns An object containing a formatted dataset and its associated dates,
197 * or `{ dataset: null, dates: [] }` when the input is incompatible
198 */
199function formatXyDataset(
200 selectedGranularity: ChartTimeGranularity,
201 dataset: EvolutionData,
202 seriesName: string,
203): { dataset: VueUiXyDatasetItem[] | null; dates: number[] } {
204 if (selectedGranularity === 'weekly' && isWeeklyDataset(dataset)) {
205 return {
206 dataset: [
207 {
208 name: seriesName,
209 type: 'line',
210 series: dataset.map(d => d.value),
211 color: accent.value,
212 useArea: true,
213 },
214 ],
215 dates: dataset.map(d => d.timestampEnd),
216 }
217 }
218 if (selectedGranularity === 'daily' && isDailyDataset(dataset)) {
219 return {
220 dataset: [
221 {
222 name: seriesName,
223 type: 'line',
224 series: dataset.map(d => d.value),
225 color: accent.value,
226 useArea: true,
227 },
228 ],
229 dates: dataset.map(d => d.timestamp),
230 }
231 }
232 if (selectedGranularity === 'monthly' && isMonthlyDataset(dataset)) {
233 return {
234 dataset: [
235 {
236 name: seriesName,
237 type: 'line',
238 series: dataset.map(d => d.value),
239 color: accent.value,
240 useArea: true,
241 },
242 ],
243 dates: dataset.map(d => d.timestamp),
244 }
245 }
246 if (selectedGranularity === 'yearly' && isYearlyDataset(dataset)) {
247 return {
248 dataset: [
249 {
250 name: seriesName,
251 type: 'line',
252 series: dataset.map(d => d.value),
253 color: accent.value,
254 useArea: true,
255 },
256 ],
257 dates: dataset.map(d => d.timestamp),
258 }
259 }
260 return { dataset: null, dates: [] }
261}
262
263/**
264 * Extracts normalized time-series points from an evolution dataset based on
265 * the selected time granularity.
266 *
267 * Each returned point contains:
268 * - `timestamp`: the numeric time value used for x-axis alignment
269 * - `value`: the corresponding value at that time
270 *
271 * The timestamp field is selected according to granularity:
272 * - **daily** → `timestamp`
273 * - **weekly** → `timestampEnd`
274 * - **monthly** → `timestamp`
275 * - **yearly** → `timestamp`
276 *
277 * If the dataset does not match the expected shape for the given granularity,
278 * an empty array is returned.
279 *
280 * This helper is primarily used in multi-package mode to align multiple
281 * datasets on a shared time axis.
282 *
283 * @param selectedGranularity - Active chart time granularity
284 * @param dataset - Raw evolution dataset to extract points from
285 * @returns An array of normalized `{ timestamp, value }` points
286 */
287function extractSeriesPoints(
288 selectedGranularity: ChartTimeGranularity,
289 dataset: EvolutionData,
290): Array<{ timestamp: number; value: number }> {
291 if (selectedGranularity === 'weekly' && isWeeklyDataset(dataset)) {
292 return dataset.map(d => ({ timestamp: d.timestampEnd, value: d.value }))
293 }
294 if (
295 (selectedGranularity === 'daily' && isDailyDataset(dataset)) ||
296 (selectedGranularity === 'monthly' && isMonthlyDataset(dataset)) ||
297 (selectedGranularity === 'yearly' && isYearlyDataset(dataset))
298 ) {
299 return (dataset as Array<{ timestamp: number; value: number }>).map(d => ({
300 timestamp: d.timestamp,
301 value: d.value,
302 }))
303 }
304 return []
305}
306
307function toIsoDateOnly(value: string): string {
308 return value.slice(0, 10)
309}
310function isValidIsoDateOnly(value: string): boolean {
311 return /^\d{4}-\d{2}-\d{2}$/.test(value)
312}
313function safeMin(a: string, b: string): string {
314 return a.localeCompare(b) <= 0 ? a : b
315}
316function safeMax(a: string, b: string): string {
317 return a.localeCompare(b) >= 0 ? a : b
318}
319
320/**
321 * Multi-package mode detection:
322 * packageNames has entries, and packageName is not set.
323 */
324const isMultiPackageMode = computed(() => {
325 const names = (props.packageNames ?? []).map(n => String(n).trim()).filter(Boolean)
326 const single = String(props.packageName ?? '').trim()
327 return names.length > 0 && !single
328})
329
330const effectivePackageNames = computed<string[]>(() => {
331 if (isMultiPackageMode.value)
332 return (props.packageNames ?? []).map(n => String(n).trim()).filter(Boolean)
333 const single = String(props.packageName ?? '').trim()
334 return single ? [single] : []
335})
336
337const {
338 fetchPackageDownloadEvolution,
339 fetchPackageLikesEvolution,
340 fetchRepoContributorsEvolution,
341 fetchRepoRefsForPackages,
342} = useCharts()
343
344const repoRefsByPackage = shallowRef<Record<string, RepoRef | null>>({})
345const repoRefsRequestToken = shallowRef(0)
346
347watch(
348 () => effectivePackageNames.value,
349 async names => {
350 if (!import.meta.client) return
351 if (!isMultiPackageMode.value) {
352 repoRefsByPackage.value = {}
353 return
354 }
355 const currentToken = ++repoRefsRequestToken.value
356 const refs = await fetchRepoRefsForPackages(names)
357 if (currentToken !== repoRefsRequestToken.value) return
358 repoRefsByPackage.value = refs
359 },
360 { immediate: true },
361)
362
363const selectedGranularity = usePermalink<ChartTimeGranularity>('granularity', DEFAULT_GRANULARITY, {
364 permanent: props.permalink,
365})
366
367const displayedGranularity = shallowRef<ChartTimeGranularity>(DEFAULT_GRANULARITY)
368
369const isEndDateOnPeriodEnd = computed(() => {
370 const g = selectedGranularity.value
371 if (g !== 'monthly' && g !== 'yearly') return false
372
373 const iso = String(endDate.value ?? '').slice(0, 10)
374 if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false
375
376 const [year, month, day] = iso.split('-').map(Number)
377 if (!year || !month || !day) return false
378
379 // Monthly: endDate is the last day of its month (UTC)
380 if (g === 'monthly') {
381 const lastDayOfMonth = new Date(Date.UTC(year, month, 0)).getUTCDate()
382 return day === lastDayOfMonth
383 }
384
385 // Yearly: endDate is the last day of the year (UTC)
386 return month === 12 && day === 31
387})
388
389const isEstimationGranularity = computed(
390 () => displayedGranularity.value === 'monthly' || displayedGranularity.value === 'yearly',
391)
392const supportsEstimation = computed(
393 () => isEstimationGranularity.value && selectedMetric.value !== 'contributors',
394)
395const shouldRenderEstimationOverlay = computed(() => !pending.value && supportsEstimation.value)
396
397const startDate = usePermalink<string>('start', '', {
398 permanent: props.permalink,
399})
400const endDate = usePermalink<string>('end', '', {
401 permanent: props.permalink,
402})
403
404const hasUserEditedDates = shallowRef(false)
405
406/**
407 * Initializes the date range from the provided weeklyDownloads dataset.
408 *
409 * The range is inferred directly from the dataset boundaries:
410 * - `startDate` is set from the `weekStart` of the first entry
411 * - `endDate` is set from the `weekEnd` of the last entry
412 *
413 * Dates are normalized to `YYYY-MM-DD` and validated before assignment.
414 *
415 * This function is a no-op when:
416 * - the user has already edited the date range
417 * - no weekly download data is available
418 *
419 * The inferred range takes precedence over client-side fallbacks but does not
420 * override user-defined dates.
421 */
422function initDateRangeFromWeekly() {
423 if (hasUserEditedDates.value) return
424 if (!props.weeklyDownloads?.length) return
425
426 const first = props.weeklyDownloads[0]
427 const last = props.weeklyDownloads[props.weeklyDownloads.length - 1]
428 const start = first?.weekStart ? toIsoDateOnly(first.weekStart) : ''
429 const end = last?.weekEnd ? toIsoDateOnly(last.weekEnd) : ''
430 if (isValidIsoDateOnly(start)) startDate.value = start
431 if (isValidIsoDateOnly(end)) endDate.value = end
432}
433
434/**
435 * Initializes a default date range on the client when no explicit dates
436 * have been provided and the user has not manually edited the range, typically
437 * when weeklyDownloads is not provided.
438 *
439 * The range is computed in UTC to avoid timezone-related off-by-one errors:
440 * - `endDate` is set to yesterday (UTC)
441 * - `startDate` is set to 29 days before yesterday (UTC), yielding a 30-day range
442 *
443 * This function is a no-op when:
444 * - the user has already edited the date range
445 * - the code is running on the server
446 * - both `startDate` and `endDate` are already defined
447 */
448function initDateRangeFallbackClient() {
449 if (hasUserEditedDates.value) return
450 if (!import.meta.client) return
451 if (startDate.value && endDate.value) return
452
453 const today = new Date()
454 const yesterday = new Date(
455 Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 1),
456 )
457 const end = yesterday.toISOString().slice(0, 10)
458
459 const startObj = new Date(yesterday)
460 startObj.setUTCDate(startObj.getUTCDate() - 29)
461 const start = startObj.toISOString().slice(0, 10)
462
463 if (!startDate.value) startDate.value = start
464 if (!endDate.value) endDate.value = end
465}
466
467function toUtcDateOnly(date: Date): string {
468 return date.toISOString().slice(0, 10)
469}
470
471function addUtcDays(date: Date, days: number): Date {
472 const next = new Date(date)
473 next.setUTCDate(next.getUTCDate() + days)
474 return next
475}
476
477/**
478 * Initializes a default date range for multi-package mode using a fixed
479 * 52-week rolling window.
480 *
481 * The range is computed in UTC to ensure consistent boundaries across
482 * timezones:
483 * - `endDate` is set to yesterday (UTC)
484 * - `startDate` is set to the first day of the 52-week window ending yesterday
485 *
486 * This function is intended for multi-package comparisons where no explicit
487 * date range or dataset-derived range is available.
488 *
489 * This function is a no-op when:
490 * - the user has already edited the date range
491 * - the code is running on the server
492 * - the component is not in multi-package mode
493 * - both `startDate` and `endDate` are already defined
494 */
495function initDateRangeForMultiPackageWeekly52() {
496 if (hasUserEditedDates.value) return
497 if (!import.meta.client) return
498 if (!isMultiPackageMode.value) return
499 if (startDate.value && endDate.value) return
500
501 const today = new Date()
502 const yesterday = new Date(
503 Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 1),
504 )
505
506 endDate.value = toUtcDateOnly(yesterday)
507 startDate.value = toUtcDateOnly(addUtcDays(yesterday, -(52 * 7) + 1))
508}
509
510watch(
511 () => (props.packageNames ?? []).length,
512 () => {
513 initDateRangeForMultiPackageWeekly52()
514 },
515 { immediate: true },
516)
517
518const initialStartDate = shallowRef<string>('') // YYYY-MM-DD
519const initialEndDate = shallowRef<string>('') // YYYY-MM-DD
520
521function setInitialRangeIfEmpty() {
522 if (initialStartDate.value || initialEndDate.value) return
523 if (startDate.value) initialStartDate.value = startDate.value
524 if (endDate.value) initialEndDate.value = endDate.value
525}
526
527watch(
528 [startDate, endDate],
529 () => {
530 if (startDate.value || endDate.value) hasUserEditedDates.value = true
531 setInitialRangeIfEmpty()
532 },
533 { immediate: true, flush: 'post' },
534)
535
536const showResetButton = computed(() => {
537 if (!initialStartDate.value && !initialEndDate.value) return false
538 return startDate.value !== initialStartDate.value || endDate.value !== initialEndDate.value
539})
540
541function resetDateRange() {
542 hasUserEditedDates.value = false
543 startDate.value = ''
544 endDate.value = ''
545 initDateRangeFromWeekly()
546 initDateRangeForMultiPackageWeekly52()
547 initDateRangeFallbackClient()
548}
549
550const options = shallowRef<
551 | { granularity: 'day'; startDate?: string; endDate?: string }
552 | { granularity: 'week'; weeks: number; startDate?: string; endDate?: string }
553 | {
554 granularity: 'month'
555 months: number
556 startDate?: string
557 endDate?: string
558 }
559 | { granularity: 'year'; startDate?: string; endDate?: string }
560>({ granularity: 'week', weeks: 52 })
561
562/**
563 * Applies the current date range (`startDate` / `endDate`) to a base options
564 * object, returning a new object augmented with validated date fields.
565 *
566 * Dates are normalized to `YYYY-MM-DD`, validated, and ordered to ensure
567 * logical consistency:
568 * - When both dates are valid, the earliest is assigned to `startDate` and
569 * the latest to `endDate`
570 * - When only one valid date is present, only that boundary is applied
571 * - Invalid or empty dates are omitted from the result
572 *
573 * The input object is not mutated.
574 *
575 * @typeParam T - Base options type to extend with date range fields
576 * @param base - Base options object to which the date range should be applied
577 * @returns A new options object including the applicable `startDate` and/or
578 * `endDate` fields
579 */
580function applyDateRange<T extends Record<string, unknown>>(base: T): T & DateRangeFields {
581 const next: T & DateRangeFields = { ...base }
582
583 const start = startDate.value ? toIsoDateOnly(startDate.value) : ''
584 const end = endDate.value ? toIsoDateOnly(endDate.value) : ''
585
586 const validStart = start && isValidIsoDateOnly(start) ? start : ''
587 const validEnd = end && isValidIsoDateOnly(end) ? end : ''
588
589 if (validStart && validEnd) {
590 next.startDate = safeMin(validStart, validEnd)
591 next.endDate = safeMax(validStart, validEnd)
592 } else {
593 if (validStart) next.startDate = validStart
594 else delete next.startDate
595
596 if (validEnd) next.endDate = validEnd
597 else delete next.endDate
598 }
599
600 return next
601}
602
603type MetricId = 'downloads' | 'likes' | 'contributors'
604const DEFAULT_METRIC_ID: MetricId = 'downloads'
605
606type MetricContext = {
607 packageName: string
608 repoRef?: RepoRef | null
609}
610
611type MetricDef = {
612 id: MetricId
613 label: string
614 fetch: (context: MetricContext, options: EvolutionOptions) => Promise<EvolutionData>
615 supportsMulti?: boolean
616}
617
618const hasContributorsFacet = computed(() => {
619 if (isMultiPackageMode.value) {
620 return Object.values(repoRefsByPackage.value).some(ref => ref?.provider === 'github')
621 }
622 const ref = props.repoRef
623 return ref?.provider === 'github' && ref.owner && ref.repo
624})
625
626const METRICS = computed<MetricDef[]>(() => {
627 const metrics: MetricDef[] = [
628 {
629 id: 'downloads',
630 label: $t('package.trends.items.downloads'),
631 fetch: ({ packageName }, opts) =>
632 fetchPackageDownloadEvolution(
633 packageName,
634 props.createdIso ?? null,
635 opts,
636 ) as Promise<EvolutionData>,
637 supportsMulti: true,
638 },
639 {
640 id: 'likes',
641 label: $t('package.trends.items.likes'),
642 fetch: ({ packageName }, opts) => fetchPackageLikesEvolution(packageName, opts),
643 supportsMulti: true,
644 },
645 ]
646
647 if (hasContributorsFacet.value) {
648 metrics.push({
649 id: 'contributors',
650 label: $t('package.trends.items.contributors'),
651 fetch: ({ repoRef }, opts) => fetchRepoContributorsEvolution(repoRef, opts),
652 supportsMulti: true,
653 })
654 }
655
656 return metrics
657})
658
659const selectedMetric = usePermalink<MetricId>('facet', DEFAULT_METRIC_ID, {
660 permanent: props.permalink,
661})
662
663const effectivePackageNamesForMetric = computed<string[]>(() => {
664 if (!isMultiPackageMode.value) return effectivePackageNames.value
665 if (selectedMetric.value !== 'contributors') return effectivePackageNames.value
666 return effectivePackageNames.value.filter(
667 name => repoRefsByPackage.value[name]?.provider === 'github',
668 )
669})
670
671const skippedPackagesWithoutGitHub = computed(() => {
672 if (!isMultiPackageMode.value) return []
673 if (selectedMetric.value !== 'contributors') return []
674 if (!effectivePackageNames.value.length) return []
675
676 return effectivePackageNames.value.filter(
677 name => repoRefsByPackage.value[name]?.provider !== 'github',
678 )
679})
680
681const availableGranularities = computed<ChartTimeGranularity[]>(() => {
682 if (selectedMetric.value === 'contributors') {
683 return ['weekly', 'monthly', 'yearly']
684 }
685
686 return ['daily', 'weekly', 'monthly', 'yearly']
687})
688
689watch(
690 () => [selectedMetric.value, availableGranularities.value] as const,
691 () => {
692 if (!availableGranularities.value.includes(selectedGranularity.value)) {
693 selectedGranularity.value = 'weekly'
694 }
695 },
696 { immediate: true },
697)
698
699watch(
700 () => METRICS.value,
701 metrics => {
702 if (!metrics.some(m => m.id === selectedMetric.value)) {
703 selectedMetric.value = DEFAULT_METRIC_ID
704 }
705 },
706 { immediate: true },
707)
708
709// Per-metric state keyed by metric id
710const metricStates = reactive<
711 Record<
712 MetricId,
713 {
714 pending: boolean
715 evolution: EvolutionData
716 evolutionsByPackage: Record<string, EvolutionData>
717 requestToken: number
718 }
719 >
720>({
721 downloads: {
722 pending: false,
723 evolution: props.weeklyDownloads ?? [],
724 evolutionsByPackage: {},
725 requestToken: 0,
726 },
727 likes: {
728 pending: false,
729 evolution: [],
730 evolutionsByPackage: {},
731 requestToken: 0,
732 },
733 contributors: {
734 pending: false,
735 evolution: [],
736 evolutionsByPackage: {},
737 requestToken: 0,
738 },
739})
740
741const activeMetricState = computed(() => metricStates[selectedMetric.value])
742const activeMetricDef = computed(
743 () => METRICS.value.find(m => m.id === selectedMetric.value) ?? METRICS.value[0],
744)
745const pending = computed(() => activeMetricState.value.pending)
746
747const isMounted = shallowRef(false)
748
749// Watches granularity and date inputs to keep request options in sync and
750// manage the loading state.
751//
752// This watcher does NOT perform the fetch itself. Its responsibilities are:
753// - derive the correct API options from the selected granularity
754// - apply the current validated date range to those options
755// - determine whether a loading indicator should be shown
756//
757// Fetching is debounced separately to avoid excessive
758// network requests while the user is interacting with controls.
759watch(
760 [selectedGranularity, startDate, endDate],
761 ([granularityValue]) => {
762 if (granularityValue === 'daily') options.value = applyDateRange({ granularity: 'day' })
763 else if (granularityValue === 'weekly')
764 options.value = applyDateRange({ granularity: 'week', weeks: 52 })
765 else if (granularityValue === 'monthly')
766 options.value = applyDateRange({ granularity: 'month', months: 24 })
767 else options.value = applyDateRange({ granularity: 'year' })
768
769 // Do not set pending during initial setup
770 if (!isMounted.value) return
771
772 const packageNames = effectivePackageNames.value
773 if (!import.meta.client || !packageNames.length) {
774 activeMetricState.value.pending = false
775 return
776 }
777
778 const o = options.value
779 const hasExplicitRange = ('startDate' in o && o.startDate) || ('endDate' in o && o.endDate)
780
781 // Do not show loading when weeklyDownloads is already provided
782 if (
783 selectedMetric.value === DEFAULT_METRIC_ID &&
784 !isMultiPackageMode.value &&
785 granularityValue === DEFAULT_GRANULARITY &&
786 props.weeklyDownloads?.length &&
787 !hasExplicitRange
788 ) {
789 activeMetricState.value.pending = false
790 return
791 }
792
793 activeMetricState.value.pending = true
794 },
795 { immediate: true },
796)
797
798/**
799 * Fetches evolution data for a given metric based on the current granularity,
800 * date range, and package selection.
801 *
802 * This function:
803 * - runs only on the client
804 * - supports both single-package and multi-package modes
805 * - applies request de-duplication via a request token to avoid race conditions
806 * - updates the appropriate reactive stores with fetched data
807 * - manages the metric's `pending` loading state
808 */
809async function loadMetric(metricId: MetricId) {
810 if (!import.meta.client) return
811
812 const state = metricStates[metricId]
813 const metric = METRICS.value.find(m => m.id === metricId)!
814 const currentToken = ++state.requestToken
815 state.pending = true
816
817 const fetchFn = (context: MetricContext) => metric.fetch(context, options.value)
818
819 try {
820 const packageNames = effectivePackageNamesForMetric.value
821 if (!packageNames.length) {
822 if (isMultiPackageMode.value) state.evolutionsByPackage = {}
823 else state.evolution = []
824 displayedGranularity.value = selectedGranularity.value
825 return
826 }
827
828 if (isMultiPackageMode.value) {
829 if (metric.supportsMulti === false) {
830 state.evolutionsByPackage = {}
831 displayedGranularity.value = selectedGranularity.value
832 return
833 }
834
835 const settled = await Promise.allSettled(
836 packageNames.map(async pkg => {
837 const repoRef = metricId === 'contributors' ? repoRefsByPackage.value[pkg] : null
838 const result = await fetchFn({ packageName: pkg, repoRef })
839 return { pkg, result: (result ?? []) as EvolutionData }
840 }),
841 )
842
843 if (currentToken !== state.requestToken) return
844
845 const next: Record<string, EvolutionData> = {}
846 for (const entry of settled) {
847 if (entry.status === 'fulfilled') next[entry.value.pkg] = entry.value.result
848 }
849
850 state.evolutionsByPackage = next
851 displayedGranularity.value = selectedGranularity.value
852 return
853 }
854
855 const pkg = packageNames[0] ?? ''
856 if (!pkg) {
857 state.evolution = []
858 displayedGranularity.value = selectedGranularity.value
859 return
860 }
861
862 // In single-package mode the parent already fetches weekly downloads for the
863 // sparkline (WeeklyDownloadStats). When the user hasn't customised the date
864 // range we can reuse that prop directly and skip a redundant API call.
865 if (metricId === DEFAULT_METRIC_ID) {
866 const o = options.value
867 const hasExplicitRange = ('startDate' in o && o.startDate) || ('endDate' in o && o.endDate)
868 if (
869 selectedGranularity.value === DEFAULT_GRANULARITY &&
870 props.weeklyDownloads?.length &&
871 !hasExplicitRange
872 ) {
873 state.evolution = props.weeklyDownloads
874 displayedGranularity.value = DEFAULT_GRANULARITY
875 return
876 }
877 }
878
879 const result = await fetchFn({ packageName: pkg, repoRef: props.repoRef })
880 if (currentToken !== state.requestToken) return
881
882 state.evolution = (result ?? []) as EvolutionData
883 displayedGranularity.value = selectedGranularity.value
884 } catch {
885 if (currentToken !== state.requestToken) return
886 if (isMultiPackageMode.value) state.evolutionsByPackage = {}
887 else state.evolution = []
888 } finally {
889 if (currentToken === state.requestToken) state.pending = false
890 }
891}
892
893// Debounced wrapper around `loadNow` to avoid triggering a network request
894// on every intermediate state change while the user is interacting with inputs
895//
896// This 'arbitrary' 1000 ms delay:
897// - gives enough time for the user to finish changing granularity or dates
898// - prevents unnecessary API load and visual flicker of the loading state
899//
900const debouncedLoadNow = useDebounceFn(() => {
901 loadMetric(selectedMetric.value)
902}, 1000)
903
904const fetchTriggerKey = computed(() => {
905 const names = effectivePackageNames.value.join(',')
906 const o = options.value
907 const repoKey = props.repoRef
908 ? `${props.repoRef.provider}:${props.repoRef.owner}/${props.repoRef.repo}`
909 : ''
910 return [
911 isMultiPackageMode.value ? 'M' : 'S',
912 names,
913 repoKey,
914 String(props.createdIso ?? ''),
915 String(o.granularity ?? ''),
916 String('weeks' in o ? (o.weeks ?? '') : ''),
917 String('months' in o ? (o.months ?? '') : ''),
918 String('startDate' in o ? (o.startDate ?? '') : ''),
919 String('endDate' in o ? (o.endDate ?? '') : ''),
920 ].join('|')
921})
922
923watch(
924 () => fetchTriggerKey.value,
925 () => {
926 if (!import.meta.client) return
927 if (!isMounted.value) return
928 debouncedLoadNow()
929 },
930 { flush: 'post' },
931)
932
933watch(
934 () => repoRefsByPackage.value,
935 () => {
936 if (!import.meta.client) return
937 if (!isMounted.value) return
938 if (!isMultiPackageMode.value) return
939 if (selectedMetric.value !== 'contributors') return
940 debouncedLoadNow()
941 },
942 { deep: true },
943)
944
945const effectiveDataSingle = computed<EvolutionData>(() => {
946 const state = activeMetricState.value
947 if (
948 selectedMetric.value === DEFAULT_METRIC_ID &&
949 displayedGranularity.value === DEFAULT_GRANULARITY &&
950 props.weeklyDownloads?.length
951 ) {
952 if (isWeeklyDataset(state.evolution) && state.evolution.length) return state.evolution
953 return props.weeklyDownloads
954 }
955 return state.evolution
956})
957
958/**
959 * Normalized chart data derived from the active metric's evolution datasets.
960 *
961 * Adapts its behavior based on the current mode:
962 * - **Single-package mode**: formats via `formatXyDataset`
963 * - **Multi-package mode**: merges datasets into a shared time axis
964
965 * The returned structure matches the expectations of `VueUiXy`:
966 * - `dataset`: array of series definitions, or `null` when no data is available
967 * - `dates`: sorted list of timestamps used as the x-axis reference
968 *
969 * Returning `dataset: null` explicitly signals the absence of data and allows
970 * the template to handle empty states without ambiguity.
971 */
972const chartData = computed<{
973 dataset: VueUiXyDatasetItem[] | null
974 dates: number[]
975}>(() => {
976 if (!isMultiPackageMode.value) {
977 const pkg = effectivePackageNames.value[0] ?? props.packageName ?? ''
978 return formatXyDataset(displayedGranularity.value, effectiveDataSingle.value, pkg)
979 }
980
981 const state = activeMetricState.value
982 const names = effectivePackageNamesForMetric.value
983 const granularity = displayedGranularity.value
984
985 const timestampSet = new Set<number>()
986 const pointsByPackage = new Map<string, Array<{ timestamp: number; value: number }>>()
987
988 for (const pkg of names) {
989 const data = state.evolutionsByPackage[pkg] ?? []
990 const points = extractSeriesPoints(granularity, data)
991 pointsByPackage.set(pkg, points)
992 for (const p of points) timestampSet.add(p.timestamp)
993 }
994
995 const dates = Array.from(timestampSet).sort((a, b) => a - b)
996 if (!dates.length) return { dataset: null, dates: [] }
997
998 const dataset: VueUiXyDatasetItem[] = names.map(pkg => {
999 const points = pointsByPackage.get(pkg) ?? []
1000 const map = new Map<number, number>()
1001 for (const p of points) map.set(p.timestamp, p.value)
1002
1003 const series = dates.map(t => map.get(t) ?? 0)
1004
1005 const item: VueUiXyDatasetItem = {
1006 name: pkg,
1007 type: 'line',
1008 series,
1009 } as VueUiXyDatasetItem
1010
1011 if (isListedFramework(pkg)) {
1012 item.color = getFrameworkColor(pkg)
1013 }
1014 return item
1015 })
1016
1017 return { dataset, dates }
1018})
1019
1020const normalisedDataset = computed(() => {
1021 return chartData.value.dataset?.map(d => {
1022 const lastValue = d.series.at(-1) ?? 0
1023
1024 // Contributors is an absolute metric: keep the partial period value as-is.
1025 const projectedLastValue =
1026 selectedMetric.value === 'contributors' ? lastValue : extrapolateLastValue(lastValue)
1027
1028 return {
1029 ...d,
1030 series: [...d.series.slice(0, -1), projectedLastValue],
1031 }
1032 })
1033})
1034
1035const maxDatapoints = computed(() =>
1036 Math.max(0, ...(chartData.value.dataset ?? []).map(d => d.series.length)),
1037)
1038
1039const loadFile = (link: string, filename: string) => {
1040 const a = document.createElement('a')
1041 a.href = link
1042 a.download = filename
1043 a.click()
1044 a.remove()
1045}
1046
1047const datetimeFormatterOptions = computed(() => {
1048 return {
1049 daily: { year: 'yyyy-MM-dd', month: 'yyyy-MM-dd', day: 'yyyy-MM-dd' },
1050 weekly: { year: 'yyyy-MM-dd', month: 'yyyy-MM-dd', day: 'yyyy-MM-dd' },
1051 monthly: { year: 'MMM yyyy', month: 'MMM yyyy', day: 'MMM yyyy' },
1052 yearly: { year: 'yyyy', month: 'yyyy', day: 'yyyy' },
1053 }[selectedGranularity.value]
1054})
1055
1056const sanitise = (value: string) =>
1057 value
1058 .replace(/^@/, '')
1059 .replace(/[\\/:"*?<>|]/g, '-')
1060 .replace(/\//g, '-')
1061
1062function buildExportFilename(extension: string): string {
1063 const g = selectedGranularity.value
1064 const range = `${startDate.value}_${endDate.value}`
1065
1066 if (!isMultiPackageMode.value) {
1067 const name = effectivePackageNames.value[0] ?? props.packageName ?? 'package'
1068 return `${sanitise(name)}-${g}_${range}.${extension}`
1069 }
1070
1071 const names = effectivePackageNames.value
1072 const label = names.length === 1 ? names[0] : names.join('_')
1073 return `${sanitise(label ?? '')}-${g}_${range}.${extension}`
1074}
1075
1076const granularityLabels = computed(() => ({
1077 daily: $t('package.trends.granularity_daily'),
1078 weekly: $t('package.trends.granularity_weekly'),
1079 monthly: $t('package.trends.granularity_monthly'),
1080 yearly: $t('package.trends.granularity_yearly'),
1081}))
1082
1083function getGranularityLabel(granularity: ChartTimeGranularity) {
1084 return granularityLabels.value[granularity]
1085}
1086
1087const granularityItems = computed(() =>
1088 availableGranularities.value.map(granularity => ({
1089 label: granularityLabels.value[granularity],
1090 value: granularity,
1091 })),
1092)
1093
1094function clampRatio(value: number): number {
1095 if (value < 0) return 0
1096 if (value > 1) return 1
1097 return value
1098}
1099
1100/**
1101 * Convert a `YYYY-MM-DD` date to UTC timestamp representing the end of that day.
1102 * The returned timestamp corresponds to `23:59:59.999` in UTC
1103 *
1104 * @param endDateOnly - ISO-like date string (`YYYY-MM-DD`)
1105 * @returns The UTC timestamp in milliseconds for the end of the given day,
1106 * or `null` if the input is invalid.
1107 */
1108function endDateOnlyToUtcMs(endDateOnly: string): number | null {
1109 if (!/^\d{4}-\d{2}-\d{2}$/.test(endDateOnly)) return null
1110 const [y, m, d] = endDateOnly.split('-').map(Number)
1111 if (!y || !m || !d) return null
1112 return Date.UTC(y, m - 1, d, 23, 59, 59, 999)
1113}
1114
1115/**
1116 * Computes the UTC timestamp corresponding to the start of the time bucket
1117 * that contains the given timestamp.
1118 *
1119 * This function is used to derive period boundaries when computing completion
1120 * ratios or extrapolating values for partially completed periods.
1121 *
1122 * Bucket boundaries are defined in UTC:
1123 * - **monthly** : first day of the month at `00:00:00.000` UTC
1124 * - **yearly** : January 1st of the year at `00:00:00.000` UTC
1125 *
1126 * @param timestampMs - Reference timestamp in milliseconds
1127 * @param granularity - Bucket granularity (`monthly` or `yearly`)
1128 * @returns The UTC timestamp representing the start of the corresponding
1129 * time bucket.
1130 */
1131function getBucketStartUtc(timestampMs: number, granularity: 'monthly' | 'yearly'): number {
1132 const date = new Date(timestampMs)
1133 if (granularity === 'yearly') return Date.UTC(date.getUTCFullYear(), 0, 1, 0, 0, 0, 0)
1134 return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0)
1135}
1136
1137/**
1138 * Computes the UTC timestamp corresponding to the end of the time
1139 * bucket that contains the given timestamp. This end timestamp is paired with `getBucketStartUtc` to define
1140 * a half-open interval `[start, end)` when computing elapsed time or completion
1141 * ratios within a period.
1142 *
1143 * Bucket boundaries are defined in UTC and are **exclusive**:
1144 * - **monthly** : first day of the following month at `00:00:00.000` UTC
1145 * - **yearly** : January 1st of the following year at `00:00:00.000` UTC
1146 *
1147 * @param timestampMs - Reference timestamp in milliseconds
1148 * @param granularity - Bucket granularity (`monthly` or `yearly`)
1149 * @returns The UTC timestamp (in milliseconds) representing the exclusive end
1150 * of the corresponding time bucket.
1151 */
1152function getBucketEndUtc(timestampMs: number, granularity: 'monthly' | 'yearly'): number {
1153 const date = new Date(timestampMs)
1154 if (granularity === 'yearly') return Date.UTC(date.getUTCFullYear() + 1, 0, 1, 0, 0, 0, 0)
1155 return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1, 0, 0, 0, 0)
1156}
1157
1158/**
1159 * Computes the completion ratio of a time bucket relative to a reference time.
1160 *
1161 * The ratio represents how much of the bucket’s duration has elapsed at
1162 * `referenceMs`, expressed as a normalized value in the range `[0, 1]`.
1163 *
1164 * The bucket is defined by the calendar period (monthly or yearly) that
1165 * contains `bucketTimestampMs`, using UTC boundaries:
1166 * - start: `getBucketStartUtc(...)`
1167 * - end: `getBucketEndUtc(...)`
1168 *
1169 * The returned value is clamped to `[0, 1]`:
1170 * - `0`: reference time is at or before the start of the bucket
1171 * - `1`: reference time is at or after the end of the bucket
1172 *
1173 * This function is used to detect partially completed periods and to
1174 * extrapolate full period values from partial data.
1175 *
1176 * @param params.bucketTimestampMs - Timestamp belonging to the bucket
1177 * @param params.granularity - Bucket granularity (`monthly` or `yearly`)
1178 * @param params.referenceMs - Reference timestamp used to measure progress
1179 * @returns A normalized completion ratio in the range `[0, 1]`.
1180 */
1181function getCompletionRatioForBucket(params: {
1182 bucketTimestampMs: number
1183 granularity: 'monthly' | 'yearly'
1184 referenceMs: number
1185}): number {
1186 const start = getBucketStartUtc(params.bucketTimestampMs, params.granularity)
1187 const end = getBucketEndUtc(params.bucketTimestampMs, params.granularity)
1188 const total = end - start
1189 if (total <= 0) return 1
1190 return clampRatio((params.referenceMs - start) / total)
1191}
1192
1193/**
1194 * Extrapolate the last observed value of a time series when the last bucket
1195 * (month or year) is only partially complete.
1196 *
1197 * This is used to replace the final value in each `VueUiXy` series
1198 * before rendering, so the chart can display an estimated full-period value
1199 * for the current month or year.
1200 *
1201 * Notes:
1202 * - This function assumes `lastValue` is the value corresponding to the last
1203 * date in `chartData.value.dates`
1204 *
1205 * @param lastValue - The last observed numeric value for a series.
1206 * @returns The extrapolated value for partially completed monthly or yearly granularities,
1207 * or the original `lastValue` when no extrapolation should be applied.
1208 */
1209function extrapolateLastValue(lastValue: number) {
1210 if (selectedMetric.value === 'contributors') return lastValue
1211
1212 if (displayedGranularity.value !== 'monthly' && displayedGranularity.value !== 'yearly')
1213 return lastValue
1214
1215 const endDateMs = endDate.value ? endDateOnlyToUtcMs(endDate.value) : null
1216 const referenceMs = endDateMs ?? Date.now()
1217
1218 const completionRatio = getCompletionRatioForBucket({
1219 bucketTimestampMs: chartData.value.dates.at(-1) ?? 0,
1220 granularity: displayedGranularity.value,
1221 referenceMs,
1222 })
1223
1224 if (!(completionRatio > 0 && completionRatio < 1)) return lastValue
1225
1226 const extrapolatedValue = lastValue / completionRatio
1227 if (!Number.isFinite(extrapolatedValue)) return lastValue
1228
1229 return extrapolatedValue
1230}
1231
1232/**
1233 * Build and return svg markup for estimation overlays on the chart.
1234 *
1235 * This function is used in the `#svg` slot of `VueUiXy` to draw a dashed line
1236 * between the last datapoint and its ancestor, for partial month or year.
1237 *
1238 * The function returns an empty string when:
1239 * - estimation overlays are disabled
1240 * - no valid series or datapoints are available
1241 *
1242 * @param svg - svg context object provided by `VueUiXy` via the `#svg` slot
1243 * @returns A string containing SVG elements to be injected, or an empty string
1244 * when no estimation overlay should be rendered.
1245 */
1246function drawEstimationLine(svg: Record<string, any>) {
1247 if (!shouldRenderEstimationOverlay.value) return ''
1248
1249 const data = Array.isArray(svg?.data) ? svg.data : []
1250 if (!data.length) return ''
1251
1252 // Collect per-series estimates and a global max candidate for the y-axis
1253 const lines: string[] = []
1254
1255 for (const serie of data) {
1256 const plots = serie?.plots
1257 if (!Array.isArray(plots) || plots.length < 2) continue
1258
1259 const previousPoint = plots.at(-2)
1260 const lastPoint = plots.at(-1)
1261 if (!previousPoint || !lastPoint) continue
1262
1263 const stroke = String(serie?.color ?? colors.value.fg)
1264
1265 /**
1266 * The following svg elements are injected in the #svg slot of VueUiXy:
1267 * - a line overlay covering the plain path bewteen the last datapoint and its ancestor
1268 * - a dashed line connecting the last datapoint to its ancestor
1269 * - a circle for the last datapoint
1270 */
1271
1272 lines.push(`
1273 <line
1274 x1="${previousPoint.x}"
1275 y1="${previousPoint.y}"
1276 x2="${lastPoint.x}"
1277 y2="${lastPoint.y}"
1278 stroke="${colors.value.bg}"
1279 stroke-width="3"
1280 opacity="1"
1281 />
1282 <line
1283 x1="${previousPoint.x}"
1284 y1="${previousPoint.y}"
1285 x2="${lastPoint.x}"
1286 y2="${lastPoint.y}"
1287 stroke="${stroke}"
1288 stroke-width="3"
1289 stroke-dasharray="4 8"
1290 stroke-linecap="round"
1291 />
1292 <circle
1293 cx="${lastPoint.x}"
1294 cy="${lastPoint.y}"
1295 r="4"
1296 fill="${stroke}"
1297 stroke="${colors.value.bg}"
1298 stroke-width="2"
1299 />
1300 `)
1301 }
1302
1303 if (!lines.length) return ''
1304
1305 return lines.join('\n')
1306}
1307
1308/**
1309 * Build and return svg text label for the last datapoint of each series.
1310 *
1311 * This function is used in the `#svg` slot of `VueUiXy` to render a value label
1312 * next to the final datapoint of each series when the data represents fully
1313 * completed periods (for example, daily or weekly granularities).
1314 *
1315 * For each series:
1316 * - retrieves the last plotted point
1317 * - renders a text label slightly offset to the right of the point
1318 * - formats the value using the compact number formatter
1319 *
1320 * Return an empty string when no series data is available.
1321 *
1322 * @param svg - SVG context object provided by `VueUiXy` via the `#svg` slot
1323 * @returns A string containing SVG `<text>` elements, or an empty string when
1324 * no labels should be rendered.
1325 */
1326function drawLastDatapointLabel(svg: Record<string, any>) {
1327 const data = Array.isArray(svg?.data) ? svg.data : []
1328 if (!data.length) return ''
1329
1330 const dataLabels: string[] = []
1331
1332 for (const serie of data) {
1333 const lastPlot = serie.plots.at(-1)
1334
1335 dataLabels.push(`
1336 <text
1337 text-anchor="start"
1338 dominant-baseline="middle"
1339 x="${lastPlot.x + 12}"
1340 y="${lastPlot.y}"
1341 font-size="24"
1342 fill="${colors.value.fg}"
1343 stroke="${colors.value.bg}"
1344 stroke-width="1"
1345 paint-order="stroke fill"
1346 >
1347 ${compactNumberFormatter.value.format(Number.isFinite(lastPlot.value) ? lastPlot.value : 0)}
1348 </text>
1349 `)
1350 }
1351
1352 return dataLabels.join('\n')
1353}
1354
1355/**
1356 * Build and return a legend to be injected during the SVG export only, since the custom legend is
1357 * displayed as an independant div, content has to be injected within the chart's viewBox.
1358 *
1359 * Legend items are displayed in a column, on the top left of the chart.
1360 */
1361function drawSvgPrintLegend(svg: Record<string, any>) {
1362 const data = Array.isArray(svg?.data) ? svg.data : []
1363 if (!data.length) return ''
1364
1365 const seriesNames: string[] = []
1366
1367 data.forEach((serie, index) => {
1368 seriesNames.push(`
1369 <rect
1370 x="${svg.drawingArea.left + 12}"
1371 y="${svg.drawingArea.top + 24 * index - 7}"
1372 width="12"
1373 height="12"
1374 fill="${serie.color}"
1375 rx="3"
1376 />
1377 <text
1378 text-anchor="start"
1379 dominant-baseline="middle"
1380 x="${svg.drawingArea.left + 32}"
1381 y="${svg.drawingArea.top + 24 * index}"
1382 font-size="16"
1383 fill="${colors.value.fg}"
1384 stroke="${colors.value.bg}"
1385 stroke-width="1"
1386 paint-order="stroke fill"
1387 >
1388 ${serie.name}
1389 </text>
1390 `)
1391 })
1392
1393 // Inject the estimation legend item when necessary
1394 if (supportsEstimation.value && !isEndDateOnPeriodEnd.value && !isZoomed.value) {
1395 seriesNames.push(`
1396 <line
1397 x1="${svg.drawingArea.left + 12}"
1398 y1="${svg.drawingArea.top + 24 * data.length}"
1399 x2="${svg.drawingArea.left + 24}"
1400 y2="${svg.drawingArea.top + 24 * data.length}"
1401 stroke="${colors.value.fg}"
1402 stroke-dasharray="4"
1403 stroke-linecap="round"
1404 />
1405 <text
1406 text-anchor="start"
1407 dominant-baseline="middle"
1408 x="${svg.drawingArea.left + 32}"
1409 y="${svg.drawingArea.top + 24 * data.length}"
1410 font-size="16"
1411 fill="${colors.value.fg}"
1412 stroke="${colors.value.bg}"
1413 stroke-width="1"
1414 paint-order="stroke fill"
1415 >
1416 ${$t('package.trends.legend_estimation')}
1417 </text>
1418 `)
1419 }
1420
1421 return seriesNames.join('\n')
1422}
1423
1424// VueUiXy chart component configuration
1425const chartConfig = computed(() => {
1426 return {
1427 theme: isDarkMode.value ? 'dark' : 'default',
1428 chart: {
1429 height: isMobile.value ? 950 : 600,
1430 backgroundColor: colors.value.bg,
1431 padding: { bottom: displayedGranularity.value === 'yearly' ? 84 : 64, right: 100 }, // padding right is set to leave space of last datapoint label(s)
1432 userOptions: {
1433 buttons: {
1434 pdf: false,
1435 labels: false,
1436 fullscreen: false,
1437 table: false,
1438 tooltip: false,
1439 altCopy: false, // TODO: set to true to enable the alt copy feature
1440 },
1441 buttonTitles: {
1442 csv: $t('package.trends.download_file', { fileType: 'CSV' }),
1443 img: $t('package.trends.download_file', { fileType: 'PNG' }),
1444 svg: $t('package.trends.download_file', { fileType: 'SVG' }),
1445 annotator: $t('package.trends.toggle_annotator'),
1446 altCopy: undefined, // TODO: set to proper translation key
1447 },
1448 callbacks: {
1449 img: ({ imageUri }: { imageUri: string }) => {
1450 loadFile(imageUri, buildExportFilename('png'))
1451 },
1452 csv: (csvStr: string) => {
1453 const PLACEHOLDER_CHAR = '\0'
1454 const multilineDateTemplate = $t('package.trends.date_range_multiline', {
1455 start: PLACEHOLDER_CHAR,
1456 end: PLACEHOLDER_CHAR,
1457 })
1458 .replaceAll(PLACEHOLDER_CHAR, '')
1459 .trim()
1460 const blob = new Blob([
1461 csvStr
1462 .replace('data:text/csv;charset=utf-8,', '')
1463 .replaceAll(`\n${multilineDateTemplate}`, ` ${multilineDateTemplate}`),
1464 ])
1465 const url = URL.createObjectURL(blob)
1466 loadFile(url, buildExportFilename('csv'))
1467 URL.revokeObjectURL(url)
1468 },
1469 svg: ({ blob }: { blob: Blob }) => {
1470 const url = URL.createObjectURL(blob)
1471 loadFile(url, buildExportFilename('svg'))
1472 URL.revokeObjectURL(url)
1473 },
1474 // altCopy: ({ dataset: dst, config: cfg }: { dataset: Array<VueUiXyDatasetItem>; config: VueUiXyConfig}) => {
1475 // // TODO: implement a reusable copy-alt-text-to-clipboard feature based on the dataset & configuration
1476 // console.log({ dst, cfg})
1477 // }
1478 },
1479 },
1480 grid: {
1481 stroke: colors.value.border,
1482 showHorizontalLines: true,
1483 labels: {
1484 fontSize: isMobile.value ? 24 : 16,
1485 color: pending.value ? colors.value.border : colors.value.fgSubtle,
1486 axis: {
1487 yLabel: $t('package.trends.y_axis_label', {
1488 granularity: getGranularityLabel(selectedGranularity.value),
1489 facet: activeMetricDef.value?.label,
1490 }),
1491 yLabelOffsetX: 12,
1492 fontSize: isMobile.value ? 32 : 24,
1493 },
1494 xAxisLabels: {
1495 show: true,
1496 showOnlyAtModulo: true,
1497 modulo: 12,
1498 values: chartData.value?.dates,
1499 datetimeFormatter: {
1500 enable: true,
1501 locale: locale.value,
1502 useUTC: true,
1503 options: datetimeFormatterOptions.value,
1504 },
1505 },
1506 yAxis: {
1507 formatter: ({ value }: { value: number }) => {
1508 return compactNumberFormatter.value.format(Number.isFinite(value) ? value : 0)
1509 },
1510 useNiceScale: true, // daily/weekly -> true, monthly/yearly -> false
1511 gap: 24, // vertical gap between individual series in stacked mode
1512 },
1513 },
1514 },
1515 timeTag: {
1516 show: true,
1517 backgroundColor: colors.value.bgElevated,
1518 color: colors.value.fg,
1519 fontSize: 16,
1520 circleMarker: { radius: 3, color: colors.value.border },
1521 useDefaultFormat: true,
1522 timeFormat: 'yyyy-MM-dd HH:mm:ss',
1523 },
1524 highlighter: { useLine: true },
1525 legend: { show: false, position: 'top' },
1526 tooltip: {
1527 teleportTo: props.inModal ? '#chart-modal' : undefined,
1528 borderColor: 'transparent',
1529 backdropFilter: false,
1530 backgroundColor: 'transparent',
1531 customFormat: ({ datapoint }: { datapoint: Record<string, any> | any[] }) => {
1532 if (!datapoint) return ''
1533
1534 const items = Array.isArray(datapoint) ? datapoint : [datapoint[0]]
1535 const hasMultipleItems = items.length > 1
1536
1537 const rows = items
1538 .map((d: Record<string, any>) => {
1539 const label = String(d?.name ?? '').trim()
1540 const raw = Number(d?.value ?? 0)
1541 const v = compactNumberFormatter.value.format(Number.isFinite(raw) ? raw : 0)
1542
1543 if (!hasMultipleItems) {
1544 // We don't need the name of the package in this case, since it is shown in the xAxis label
1545 return `<div>
1546 <span class="text-base text-[var(--fg)] font-mono tabular-nums">${v}</span>
1547 </div>`
1548 }
1549
1550 return `<div class="grid grid-cols-[12px_minmax(0,1fr)_max-content] items-center gap-x-3">
1551 <div class="w-3 h-3">
1552 <svg viewBox="0 0 2 2" class="w-full h-full">
1553 <rect x="0" y="0" width="2" height="2" rx="0.3" fill="${d.color}" />
1554 </svg>
1555 </div>
1556
1557 <span class="text-3xs uppercase tracking-wide text-[var(--fg)]/70 truncate">
1558 ${label}
1559 </span>
1560
1561 <span class="text-base text-[var(--fg)] font-mono tabular-nums text-end">
1562 ${v}
1563 </span>
1564 </div>`
1565 })
1566 .join('')
1567
1568 return `<div class="font-mono text-xs p-3 border border-border rounded-md bg-[var(--bg)]/10 backdrop-blur-md">
1569 <div class="${hasMultipleItems ? 'flex flex-col gap-2' : ''}">
1570 ${rows}
1571 </div>
1572 </div>`
1573 },
1574 },
1575 zoom: {
1576 maxWidth: isMobile.value ? 350 : 500,
1577 highlightColor: colors.value.bgElevated,
1578 minimap: {
1579 show: true,
1580 lineColor: '#FAFAFA',
1581 selectedColor: accent.value,
1582 selectedColorOpacity: 0.06,
1583 frameColor: colors.value.border,
1584 handleWidth: isMobile.value ? 40 : 20, // does not affect the size of the touch area
1585 handleBorderColor: colors.value.fgSubtle,
1586 handleType: 'grab', // 'empty' | 'chevron' | 'arrow' | 'grab'
1587 },
1588 preview: {
1589 fill: transparentizeOklch(accent.value, isDarkMode.value ? 0.95 : 0.92),
1590 stroke: transparentizeOklch(accent.value, 0.5),
1591 strokeWidth: 1,
1592 strokeDasharray: 3,
1593 },
1594 },
1595 },
1596 }
1597})
1598
1599// Trigger data loading when the metric is switched
1600watch(selectedMetric, value => {
1601 if (!isMounted.value) return
1602 loadMetric(value)
1603})
1604</script>
1605
1606<template>
1607 <div
1608 class="w-full relative"
1609 id="trends-chart"
1610 :aria-busy="activeMetricState.pending ? 'true' : 'false'"
1611 >
1612 <div class="w-full mb-4 flex flex-col gap-3">
1613 <div class="flex flex-col sm:flex-row gap-3 sm:gap-2 sm:items-end">
1614 <SelectField
1615 v-if="showFacetSelector"
1616 id="trends-metric-select"
1617 v-model="selectedMetric"
1618 :disabled="activeMetricState.pending"
1619 :items="METRICS.map(m => ({ label: m.label, value: m.id }))"
1620 :label="$t('package.trends.facet')"
1621 />
1622
1623 <SelectField
1624 :label="$t('package.trends.granularity')"
1625 id="granularity"
1626 v-model="selectedGranularity"
1627 :disabled="activeMetricState.pending"
1628 :items="granularityItems"
1629 />
1630
1631 <div class="grid grid-cols-2 gap-2 flex-1">
1632 <div class="flex flex-col gap-1">
1633 <label
1634 for="startDate"
1635 class="text-2xs font-mono text-fg-subtle tracking-wide uppercase"
1636 >
1637 {{ $t('package.trends.start_date') }}
1638 </label>
1639 <div class="relative flex items-center">
1640 <span
1641 class="absolute inset-is-2 i-lucide:calendar w-4 h-4 text-fg-subtle shrink-0 pointer-events-none"
1642 aria-hidden="true"
1643 />
1644 <InputBase
1645 id="startDate"
1646 v-model="startDate"
1647 type="date"
1648 :max="DATE_INPUT_MAX"
1649 class="w-full min-w-0 bg-transparent ps-7"
1650 size="medium"
1651 />
1652 </div>
1653 </div>
1654
1655 <div class="flex flex-col gap-1">
1656 <label for="endDate" class="text-2xs font-mono text-fg-subtle tracking-wide uppercase">
1657 {{ $t('package.trends.end_date') }}
1658 </label>
1659 <div class="relative flex items-center">
1660 <span
1661 class="absolute inset-is-2 i-lucide:calendar w-4 h-4 text-fg-subtle shrink-0 pointer-events-none"
1662 aria-hidden="true"
1663 />
1664 <InputBase
1665 id="endDate"
1666 v-model="endDate"
1667 type="date"
1668 :max="DATE_INPUT_MAX"
1669 class="w-full min-w-0 bg-transparent ps-7"
1670 size="medium"
1671 />
1672 </div>
1673 </div>
1674 </div>
1675
1676 <button
1677 v-if="showResetButton"
1678 type="button"
1679 aria-label="Reset date range"
1680 class="self-end flex items-center justify-center px-2.5 py-1.75 border border-transparent rounded-md text-fg-subtle hover:text-fg transition-colors hover:border-border focus-visible:outline-accent/70 sm:mb-0"
1681 @click="resetDateRange"
1682 >
1683 <span class="i-lucide:undo-2 w-5 h-5" aria-hidden="true" />
1684 </button>
1685 </div>
1686
1687 <p v-if="skippedPackagesWithoutGitHub.length > 0" class="text-2xs font-mono text-fg-subtle">
1688 {{ $t('package.trends.contributors_skip', { count: skippedPackagesWithoutGitHub.length }) }}
1689 {{ skippedPackagesWithoutGitHub.join(', ') }}
1690 </p>
1691 </div>
1692
1693 <h2 id="trends-chart-title" class="sr-only">
1694 {{ $t('package.trends.title') }} — {{ activeMetricDef?.label }}
1695 </h2>
1696
1697 <!-- Chart panel (active metric) -->
1698 <div role="region" aria-labelledby="trends-chart-title" class="min-h-[260px]">
1699 <ClientOnly v-if="chartData.dataset">
1700 <div :data-pending="pending" :data-minimap-visible="maxDatapoints > 6">
1701 <VueUiXy
1702 :dataset="normalisedDataset"
1703 :config="chartConfig"
1704 class="[direction:ltr]"
1705 @zoomStart="setIsZoom"
1706 @zoomEnd="setIsZoom"
1707 @zoomReset="isZoomed = false"
1708 >
1709 <!-- Injecting custom svg elements -->
1710 <template #svg="{ svg }">
1711 <!-- Estimation lines for monthly & yearly granularities when the end date induces a downwards trend -->
1712 <g
1713 v-if="shouldRenderEstimationOverlay && !isEndDateOnPeriodEnd && !isZoomed"
1714 v-html="drawEstimationLine(svg)"
1715 />
1716
1717 <!-- Last value label for all other cases -->
1718 <g v-if="!pending" v-html="drawLastDatapointLabel(svg)" />
1719
1720 <!-- Inject legend during SVG print only -->
1721 <g v-if="svg.isPrintingSvg" v-html="drawSvgPrintLegend(svg)" />
1722
1723 <!-- Inject npmx logo & tagline during SVG and PNG print -->
1724 <g
1725 v-if="svg.isPrintingSvg || svg.isPrintingImg"
1726 v-html="drawNpmxLogoAndTaglineWatermark(svg, watermarkColors, $t, 'bottom')"
1727 />
1728
1729 <!-- Overlay covering the chart area to hide line resizing when switching granularities recalculates VueUiXy scaleMax when estimation lines are necessary -->
1730 <rect
1731 v-if="pending"
1732 :x="svg.drawingArea.left"
1733 :y="svg.drawingArea.top - 12"
1734 :width="svg.drawingArea.width + 12"
1735 :height="svg.drawingArea.height + 48"
1736 :fill="colors.bg"
1737 />
1738 </template>
1739
1740 <!-- Subtle gradient applied for a unique series (chart modal) -->
1741 <template #area-gradient="{ series: chartModalSeries, id: gradientId }">
1742 <linearGradient :id="gradientId" x1="0" x2="0" y1="0" y2="1">
1743 <stop offset="0%" :stop-color="chartModalSeries.color" stop-opacity="0.2" />
1744 <stop offset="100%" :stop-color="colors.bg" stop-opacity="0" />
1745 </linearGradient>
1746 </template>
1747
1748 <!-- Custom legend for multiple series -->
1749 <template #legend="{ legend }">
1750 <div class="flex gap-4 flex-wrap justify-center">
1751 <template v-if="isMultiPackageMode">
1752 <button
1753 v-for="datapoint in legend"
1754 :key="datapoint.name"
1755 :aria-pressed="datapoint.isSegregated"
1756 :aria-label="datapoint.name"
1757 type="button"
1758 class="flex gap-1 place-items-center"
1759 @click="datapoint.segregate()"
1760 >
1761 <div class="h-3 w-3">
1762 <svg viewBox="0 0 2 2" class="w-full">
1763 <rect x="0" y="0" width="2" height="2" rx="0.3" :fill="datapoint.color" />
1764 </svg>
1765 </div>
1766 <span
1767 :style="{
1768 textDecoration: datapoint.isSegregated ? 'line-through' : undefined,
1769 }"
1770 >
1771 {{ datapoint.name }}
1772 </span>
1773 </button>
1774 </template>
1775
1776 <!-- Single series legend (no user interaction) -->
1777 <template v-else-if="legend.length > 0">
1778 <div class="flex gap-1 place-items-center">
1779 <div class="h-3 w-3">
1780 <svg viewBox="0 0 2 2" class="w-full">
1781 <rect x="0" y="0" width="2" height="2" rx="0.3" :fill="legend[0]?.color" />
1782 </svg>
1783 </div>
1784 <span>
1785 {{ legend[0]?.name }}
1786 </span>
1787 </div>
1788 </template>
1789
1790 <!-- Estimation extra legend item -->
1791 <div class="flex gap-1 place-items-center" v-if="supportsEstimation">
1792 <svg viewBox="0 0 20 2" width="20">
1793 <line
1794 x1="0"
1795 y1="1"
1796 x2="20"
1797 y2="1"
1798 :stroke="colors.fg"
1799 stroke-dasharray="4"
1800 stroke-linecap="round"
1801 />
1802 </svg>
1803 <span class="text-fg-subtle">{{ $t('package.trends.legend_estimation') }}</span>
1804 </div>
1805 </div>
1806 </template>
1807
1808 <template #menuIcon="{ isOpen }">
1809 <span v-if="isOpen" class="i-lucide:x w-6 h-6" aria-hidden="true" />
1810 <span v-else class="i-lucide:ellipsis-vertical w-6 h-6" aria-hidden="true" />
1811 </template>
1812 <template #optionCsv>
1813 <span class="text-fg-subtle font-mono pointer-events-none">CSV</span>
1814 </template>
1815 <template #optionImg>
1816 <span class="text-fg-subtle font-mono pointer-events-none">PNG</span>
1817 </template>
1818 <template #optionSvg>
1819 <span class="text-fg-subtle font-mono pointer-events-none">SVG</span>
1820 </template>
1821
1822 <template #annotator-action-close>
1823 <span
1824 class="i-lucide:x w-6 h-6 text-fg-subtle"
1825 style="pointer-events: none"
1826 aria-hidden="true"
1827 />
1828 </template>
1829 <template #annotator-action-color="{ color }">
1830 <span class="i-lucide:palette w-6 h-6" :style="{ color }" aria-hidden="true" />
1831 </template>
1832 <template #annotator-action-undo>
1833 <span
1834 class="i-lucide:undo-2 w-6 h-6 text-fg-subtle"
1835 style="pointer-events: none"
1836 aria-hidden="true"
1837 />
1838 </template>
1839 <template #annotator-action-redo>
1840 <span
1841 class="i-lucide:redo-2 w-6 h-6 text-fg-subtle"
1842 style="pointer-events: none"
1843 aria-hidden="true"
1844 />
1845 </template>
1846 <template #annotator-action-delete>
1847 <span
1848 class="i-lucide:trash w-6 h-6 text-fg-subtle"
1849 style="pointer-events: none"
1850 aria-hidden="true"
1851 />
1852 </template>
1853 <template #optionAnnotator="{ isAnnotator }">
1854 <span
1855 v-if="isAnnotator"
1856 class="i-lucide:pen-off w-6 h-6 text-fg-subtle"
1857 style="pointer-events: none"
1858 aria-hidden="true"
1859 />
1860 <span
1861 v-else
1862 class="i-lucide:pen w-6 h-6 text-fg-subtle"
1863 style="pointer-events: none"
1864 aria-hidden="true"
1865 />
1866 </template>
1867 <template #optionAltCopy>
1868 <span
1869 class="i-lucide:person-standing w-6 h-6 text-fg-subtle"
1870 style="pointer-events: none"
1871 aria-hidden="true"
1872 />
1873 </template>
1874 </VueUiXy>
1875 </div>
1876
1877 <template #fallback>
1878 <div class="min-h-[260px]" />
1879 </template>
1880 </ClientOnly>
1881
1882 <div
1883 v-if="!chartData.dataset && !activeMetricState.pending"
1884 class="min-h-[260px] flex items-center justify-center text-fg-subtle font-mono text-sm"
1885 >
1886 {{ $t('package.trends.no_data') }}
1887 </div>
1888 </div>
1889
1890 <div
1891 v-if="activeMetricState.pending"
1892 role="status"
1893 aria-live="polite"
1894 class="absolute top-1/2 inset-is-1/2 -translate-x-1/2 -translate-y-1/2 text-xs text-fg-subtle font-mono bg-bg/70 backdrop-blur px-3 py-2 rounded-md border border-border"
1895 >
1896 {{ $t('package.trends.loading') }}
1897 </div>
1898 </div>
1899</template>
1900
1901<style>
1902.vue-ui-pen-and-paper-actions {
1903 background: var(--bg-elevated) !important;
1904}
1905
1906.vue-ui-pen-and-paper-action {
1907 background: var(--bg-elevated) !important;
1908 border: none !important;
1909}
1910
1911.vue-ui-pen-and-paper-action:hover {
1912 background: var(--bg-elevated) !important;
1913 box-shadow: none !important;
1914}
1915
1916/* Override default placement of the refresh button to have it to the minimap's side */
1917@media screen and (min-width: 767px) {
1918 #trends-chart .vue-data-ui-refresh-button {
1919 top: -0.6rem !important;
1920 left: calc(100% + 4rem) !important;
1921 }
1922}
1923
1924[data-pending='true'] .vue-data-ui-zoom {
1925 opacity: 0.1;
1926}
1927
1928[data-pending='true'] .vue-data-ui-time-label {
1929 opacity: 0;
1930}
1931
1932/** Override print watermark position to have it below the chart */
1933.vue-data-ui-watermark {
1934 top: unset !important;
1935}
1936
1937[data-minimap-visible='false'] .vue-data-ui-watermark {
1938 top: calc(100% - 2rem) !important;
1939}
1940</style>