[READ-ONLY] a fast, modern browser for the npm registry
at main 1940 lines 64 kB view raw
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>