@@ -9,6 +9,10 @@ import type {
99import { useElementSize } from ' @vueuse/core'
1010import { useCssVariables } from ' ~/composables/useColors'
1111import { OKLCH_NEUTRAL_FALLBACK , transparentizeOklch } from ' ~/utils/colors'
12+ import {
13+ drawSvgPrintLegend ,
14+ drawNpmxLogoAndTaglineWatermark ,
15+ } from ' ~/composables/useChartWatermark'
1216import TooltipApp from ' ~/components/Tooltip/App.vue'
1317
1418type TooltipParams = MinimalCustomFormatParams <VueUiXyDatapointItem []> & {
@@ -64,6 +68,12 @@ const accent = computed(() => {
6468 : (colors .value .fgSubtle ?? OKLCH_NEUTRAL_FALLBACK )
6569})
6670
71+ const watermarkColors = computed (() => ({
72+ fg: colors .value .fg ?? OKLCH_NEUTRAL_FALLBACK ,
73+ bg: colors .value .bg ?? OKLCH_NEUTRAL_FALLBACK ,
74+ fgSubtle: colors .value .fgSubtle ?? OKLCH_NEUTRAL_FALLBACK ,
75+ }))
76+
6777const { width } = useElementSize (rootEl )
6878const mobileBreakpointWidth = 640
6979const isMobile = computed (() => width .value > 0 && width .value < mobileBreakpointWidth )
@@ -80,6 +90,9 @@ const {
8090
8191const compactNumberFormatter = useCompactNumberFormatter ()
8292
93+ // Show loading indicator immediately to maintain stable layout
94+ const showLoadingIndicator = computed (() => pending .value )
95+
8396const chartConfig = computed (() => {
8497 return {
8598 theme: isDarkMode .value ? ' dark' : ' default' ,
@@ -89,11 +102,17 @@ const chartConfig = computed(() => {
89102 padding: {
90103 top: 24 ,
91104 right: 24 ,
92- bottom: xAxisLabels .value .length > 10 ? 100 : 72 , // More space for rotated labels
105+ bottom: xAxisLabels .value .length > 10 ? 100 : 88 , // Space for rotated labels + watermark
93106 left: isMobile .value ? 60 : 80 ,
94107 },
95108 userOptions: {
96- buttons: { pdf: false , labels: false , fullscreen: false , table: false , tooltip: false },
109+ buttons: {
110+ pdf: false ,
111+ labels: false ,
112+ fullscreen: false ,
113+ table: false ,
114+ tooltip: false ,
115+ },
97116 },
98117 grid: {
99118 stroke: colors .value .border ,
@@ -102,7 +121,6 @@ const chartConfig = computed(() => {
102121 color: pending .value ? colors .value .border : colors .value .fgSubtle ,
103122 axis: {
104123 yLabel: ' Downloads' ,
105- xLabel: props .packageName ,
106124 yLabelOffsetX: 12 ,
107125 fontSize: isMobile .value ? 32 : 24 ,
108126 },
@@ -115,17 +133,14 @@ const chartConfig = computed(() => {
115133 xAxisLabels: {
116134 show: xAxisLabels .value .length <= 25 ,
117135 values: xAxisLabels .value ,
118- fontSize: isMobile .value ? 14 : 12 ,
136+ fontSize: isMobile .value ? 16 : 14 ,
119137 color: colors .value .fgSubtle ,
120138 rotation: xAxisLabels .value .length > 10 ? 45 : 0 ,
121139 },
122140 },
123141 },
124- timeTag: {
125- show: false ,
126- },
127142 highlighter: { useLine: false },
128- legend: { show: false },
143+ legend: { show: false , position: ' top ' },
129144 bar: {
130145 periodGap: 16 ,
131146 innerGap: 8 ,
@@ -184,9 +199,6 @@ const chartConfig = computed(() => {
184199 },
185200 },
186201 },
187- userOptions: {
188- show: false ,
189- },
190202 table: {
191203 show: false ,
192204 },
@@ -199,7 +211,7 @@ const xyDataset = computed<VueUiXyDatasetItem[]>(() => {
199211
200212 return [
201213 {
202- name: ' Downloads ' ,
214+ name: props . packageName ,
203215 series: chartDataset .value .map (item => item .downloads ),
204216 type: ' bar' as const ,
205217 color: accent .value ,
@@ -247,8 +259,7 @@ const endDate = computed(() => {
247259
248260<template >
249261 <div
250- class =" w-full relative"
251- :class =" isMobile ? 'min-h-[600px]' : 'min-h-[500px]'"
262+ class =" w-full flex flex-col"
252263 id =" version-distribution"
253264 :aria-busy =" pending ? 'true' : 'false'"
254265 >
@@ -395,44 +406,165 @@ const endDate = computed(() => {
395406 <div
396407 role =" region"
397408 aria-labelledby =" version-distribution-title"
398- class =" relative flex items-center justify-center "
409+ class =" relative"
399410 :class =" isMobile ? 'min-h-[500px]' : 'min-h-[400px]'"
400411 >
401- <div
402- v-if =" pending"
403- role =" status"
404- aria-live =" polite"
405- class =" text-xs text-fg-subtle font-mono bg-bg/70 backdrop-blur px-3 py-2 rounded-md border border-border"
406- >
407- {{ $t('common.loading') }}
412+ <!-- Chart content -->
413+ <ClientOnly v-if =" xyDataset.length > 0 && !error" >
414+ <div class =" chart-container w-full" :key =" groupingMode" >
415+ <VueUiXy :dataset =" xyDataset" :config =" chartConfig" class =" [direction:ltr]" >
416+ <!-- Injecting custom svg elements -->
417+ <template #svg =" { svg } " >
418+ <!-- Inject legend during SVG print only -->
419+ <g v-if =" svg.isPrintingSvg" v-html =" drawSvgPrintLegend(svg, watermarkColors)" />
420+
421+ <!-- Inject npmx logo & tagline during SVG and PNG print -->
422+ <g
423+ v-if =" svg.isPrintingSvg || svg.isPrintingImg"
424+ v-html ="
425+ drawNpmxLogoAndTaglineWatermark(svg, watermarkColors, $t, 'belowDrawingArea')
426+ "
427+ />
428+ </template >
429+
430+ <!-- Custom legend for single series (non-interactive) -->
431+ <template #legend =" { legend } " >
432+ <div class =" flex gap-4 flex-wrap justify-center" >
433+ <template v-if =" legend .length > 0 " >
434+ <div class =" flex gap-1 place-items-center" >
435+ <div class =" h-3 w-3" >
436+ <svg viewBox =" 0 0 2 2" class =" w-full" >
437+ <rect x =" 0" y =" 0" width =" 2" height =" 2" rx =" 0.3" :fill =" legend[0]?.color" />
438+ </svg >
439+ </div >
440+ <span >
441+ {{ legend[0]?.name }}
442+ </span >
443+ </div >
444+ </template >
445+ </div >
446+ </template >
447+
448+ <!-- Contextual menu icon -->
449+ <template #menuIcon =" { isOpen } " >
450+ <span v-if =" isOpen" class =" i-carbon:close w-6 h-6" aria-hidden =" true" />
451+ <span v-else class =" i-carbon:overflow-menu-vertical w-6 h-6" aria-hidden =" true" />
452+ </template >
453+
454+ <!-- Export options -->
455+ <template #optionCsv >
456+ <span
457+ class =" i-carbon:csv w-6 h-6 text-fg-subtle"
458+ style =" pointer-events : none "
459+ aria-hidden =" true"
460+ />
461+ </template >
462+
463+ <template #optionImg >
464+ <span
465+ class =" i-carbon:png w-6 h-6 text-fg-subtle"
466+ style =" pointer-events : none "
467+ aria-hidden =" true"
468+ />
469+ </template >
470+
471+ <template #optionSvg >
472+ <span
473+ class =" i-carbon:svg w-6 h-6 text-fg-subtle"
474+ style =" pointer-events : none "
475+ aria-hidden =" true"
476+ />
477+ </template >
478+
479+ <!-- Annotator action icons -->
480+ <template #annotator-action-close >
481+ <span
482+ class =" i-carbon:close w-6 h-6 text-fg-subtle"
483+ style =" pointer-events : none "
484+ aria-hidden =" true"
485+ />
486+ </template >
487+
488+ <template #annotator-action-color =" { color } " >
489+ <span class =" i-carbon:color-palette w-6 h-6" :style =" { color }" aria-hidden =" true" />
490+ </template >
491+
492+ <template #annotator-action-undo >
493+ <span
494+ class =" i-carbon:undo w-6 h-6 text-fg-subtle"
495+ style =" pointer-events : none "
496+ aria-hidden =" true"
497+ />
498+ </template >
499+
500+ <template #annotator-action-redo >
501+ <span
502+ class =" i-carbon:redo w-6 h-6 text-fg-subtle"
503+ style =" pointer-events : none "
504+ aria-hidden =" true"
505+ />
506+ </template >
507+
508+ <template #annotator-action-delete >
509+ <span
510+ class =" i-carbon:trash-can w-6 h-6 text-fg-subtle"
511+ style =" pointer-events : none "
512+ aria-hidden =" true"
513+ />
514+ </template >
515+
516+ <template #optionAnnotator =" { isAnnotator } " >
517+ <span
518+ v-if =" isAnnotator"
519+ class =" i-carbon:edit-off w-6 h-6 text-fg-subtle"
520+ style =" pointer-events : none "
521+ aria-hidden =" true"
522+ />
523+ <span
524+ v-else
525+ class =" i-carbon:edit w-6 h-6 text-fg-subtle"
526+ style =" pointer-events : none "
527+ aria-hidden =" true"
528+ />
529+ </template >
530+ </VueUiXy >
531+ </div >
532+
533+ <template #fallback >
534+ <div />
535+ </template >
536+ </ClientOnly >
537+
538+ <!-- No-data state -->
539+ <div v-if =" !hasData && !pending && !error" class =" flex items-center justify-center h-full" >
540+ <div class =" text-sm text-fg-subtle font-mono text-center flex flex-col items-center gap-2" >
541+ <span class =" i-carbon:data-vis-4 w-8 h-8" />
542+ <p >{{ $t('package.trends.no_data') }}</p >
543+ </div >
408544 </div >
409545
410- <div
411- v-else-if =" error"
412- class =" text-sm text-fg-subtle font-mono text-center flex flex-col items-center gap-2"
413- role =" alert"
414- >
415- <span class =" i-carbon:warning-hex w-8 h-8 text-red-400" />
416- <p >{{ error.message }}</p >
417- <p class =" text-xs" >Package: {{ packageName }}</p >
546+ <!-- Error state -->
547+ <div v-if =" error" class =" flex items-center justify-center h-full" role =" alert" >
548+ <div class =" text-sm text-fg-subtle font-mono text-center flex flex-col items-center gap-2" >
549+ <span class =" i-carbon:warning-hex w-8 h-8 text-red-400" />
550+ <p >{{ error.message }}</p >
551+ <p class =" text-xs" >Package: {{ packageName }}</p >
552+ </div >
418553 </div >
419554
555+ <!-- Loading indicator as true overlay -->
420556 <div
421- v-else-if =" !hasData"
422- class =" text-sm text-fg-subtle font-mono text-center flex flex-col items-center gap-2"
557+ v-if =" showLoadingIndicator"
558+ role =" status"
559+ aria-live =" polite"
560+ class =" absolute top-1/2 inset-is-1/2 -translate-x-1/2 -translate-y-1/2"
423561 >
424- <span class =" i-carbon:data-vis-4 w-8 h-8" />
425- <p >{{ $t('package.trends.no_data') }}</p >
426- </div >
427-
428- <ClientOnly v-else-if =" xyDataset.length > 0" >
429562 <div
430- class =" chart-container w-full h-[400px] sm:h-[400px]"
431- :class =" { 'h-[500px]': isMobile }"
563+ class =" text-xs text-fg-subtle font-mono bg-bg/70 backdrop-blur px-3 py-2 rounded-md border border-border"
432564 >
433- < VueUiXy :dataset = " xyDataset " :config = " chartConfig " class = " [direction:ltr] " />
565+ {{ $t('common.loading') }}
434566 </div >
435- </ClientOnly >
567+ </div >
436568 </div >
437569 </div >
438570</template >
0 commit comments