|
| 1 | +<script setup lang="ts"> |
| 2 | +import { VueUiSparkline } from 'vue-data-ui/vue-ui-sparkline' |
| 3 | +import { useCssVariables } from '~/composables/useColors' |
| 4 | +import { |
| 5 | + type VueUiSparklineConfig, |
| 6 | + type VueUiSparklineDatasetItem, |
| 7 | + type VueUiXyDatasetItem, |
| 8 | +} from 'vue-data-ui' |
| 9 | +import { getPalette, lightenColor } from 'vue-data-ui/utils' |
| 10 | +
|
| 11 | +import('vue-data-ui/style.css') |
| 12 | +
|
| 13 | +const props = defineProps<{ |
| 14 | + dataset?: Array< |
| 15 | + VueUiXyDatasetItem & { |
| 16 | + color?: string |
| 17 | + series: number[] |
| 18 | + dashIndices?: number[] |
| 19 | + } |
| 20 | + > |
| 21 | + dates: number[] |
| 22 | + datetimeFormatterOptions: { |
| 23 | + year: string |
| 24 | + month: string |
| 25 | + day: string |
| 26 | + } |
| 27 | + showLastDatapointEstimation: boolean |
| 28 | +}>() |
| 29 | +
|
| 30 | +const { locale } = useI18n() |
| 31 | +const colorMode = useColorMode() |
| 32 | +const resolvedMode = shallowRef<'light' | 'dark'>('light') |
| 33 | +const rootEl = shallowRef<HTMLElement | null>(null) |
| 34 | +const palette = getPalette('') |
| 35 | +
|
| 36 | +const step = ref(0) |
| 37 | +
|
| 38 | +onMounted(() => { |
| 39 | + rootEl.value = document.documentElement |
| 40 | +}) |
| 41 | +
|
| 42 | +watch( |
| 43 | + () => colorMode.value, |
| 44 | + value => { |
| 45 | + resolvedMode.value = value === 'dark' ? 'dark' : 'light' |
| 46 | + }, |
| 47 | + { flush: 'sync', immediate: true }, |
| 48 | +) |
| 49 | +
|
| 50 | +const { colors } = useCssVariables( |
| 51 | + [ |
| 52 | + '--bg', |
| 53 | + '--fg', |
| 54 | + '--bg-subtle', |
| 55 | + '--bg-elevated', |
| 56 | + '--border-hover', |
| 57 | + '--fg-subtle', |
| 58 | + '--border', |
| 59 | + '--border-subtle', |
| 60 | + ], |
| 61 | + { |
| 62 | + element: rootEl, |
| 63 | + watchHtmlAttributes: true, |
| 64 | + watchResize: false, // set to true only if a var changes color on resize |
| 65 | + }, |
| 66 | +) |
| 67 | +
|
| 68 | +const isDarkMode = computed(() => resolvedMode.value === 'dark') |
| 69 | +
|
| 70 | +const datasets = computed<VueUiSparklineDatasetItem[][]>(() => { |
| 71 | + return (props.dataset ?? []).map(unit => { |
| 72 | + return props.dates.map((period, i) => { |
| 73 | + return { |
| 74 | + period, |
| 75 | + value: unit.series[i] ?? 0, |
| 76 | + } |
| 77 | + }) |
| 78 | + }) |
| 79 | +}) |
| 80 | +
|
| 81 | +const selectedIndex = ref<number | undefined | null>(null) |
| 82 | +
|
| 83 | +function hoverIndex({ index }: { index: number | undefined | null }) { |
| 84 | + if (typeof index === 'number') { |
| 85 | + selectedIndex.value = index |
| 86 | + } |
| 87 | +} |
| 88 | +
|
| 89 | +function resetHover() { |
| 90 | + selectedIndex.value = null |
| 91 | + step.value += 1 // required to reset all chart instances |
| 92 | +} |
| 93 | +
|
| 94 | +const configs = computed(() => { |
| 95 | + return (props.dataset || []).map<VueUiSparklineConfig>((unit, i) => { |
| 96 | + const lastIndex = unit.series.length - 1 |
| 97 | + const dashIndices = props.showLastDatapointEstimation |
| 98 | + ? Array.from(new Set([...(unit.dashIndices ?? []), lastIndex])) |
| 99 | + : unit.dashIndices |
| 100 | +
|
| 101 | + // Ensure we loop through available palette colours when the series count is higher than the avalable palette |
| 102 | + const fallbackColor = palette[i] ?? palette[i % palette.length] ?? palette[0]! |
| 103 | + const seriesColor = unit.color ?? fallbackColor |
| 104 | + const lightenedSeriesColor: string = unit.color |
| 105 | + ? (lightenOklch(unit.color, 0.5) ?? seriesColor) |
| 106 | + : (lightenColor(seriesColor, 0.5) ?? seriesColor) // palette uses hex colours |
| 107 | +
|
| 108 | + return { |
| 109 | + a11y: { |
| 110 | + translations: { |
| 111 | + keyboardNavigation: $t( |
| 112 | + 'package.trends.chart_assistive_text.keyboard_navigation_horizontal', |
| 113 | + ), |
| 114 | + tableAvailable: $t('package.trends.chart_assistive_text.table_available'), |
| 115 | + tableCaption: $t('package.trends.chart_assistive_text.table_caption'), |
| 116 | + }, |
| 117 | + }, |
| 118 | + theme: isDarkMode.value ? 'dark' : '', |
| 119 | + temperatureColors: { |
| 120 | + show: isDarkMode.value, |
| 121 | + colors: [lightenedSeriesColor, seriesColor], |
| 122 | + }, |
| 123 | + skeletonConfig: { |
| 124 | + style: { |
| 125 | + backgroundColor: 'transparent', |
| 126 | + dataLabel: { |
| 127 | + show: true, |
| 128 | + color: 'transparent', |
| 129 | + }, |
| 130 | + area: { |
| 131 | + color: colors.value.borderHover, |
| 132 | + useGradient: false, |
| 133 | + opacity: 10, |
| 134 | + }, |
| 135 | + line: { |
| 136 | + color: colors.value.borderHover, |
| 137 | + }, |
| 138 | + }, |
| 139 | + }, |
| 140 | + skeletonDataset: Array.from({ length: unit.series.length }, () => 0), |
| 141 | + style: { |
| 142 | + backgroundColor: 'transparent', |
| 143 | + animation: { show: false }, |
| 144 | + area: { |
| 145 | + color: colors.value.borderHover, |
| 146 | + useGradient: false, |
| 147 | + opacity: 10, |
| 148 | + }, |
| 149 | + dataLabel: { |
| 150 | + offsetX: -12, |
| 151 | + fontSize: 24, |
| 152 | + bold: false, |
| 153 | + color: colors.value.fg, |
| 154 | + datetimeFormatter: { |
| 155 | + enable: true, |
| 156 | + locale: locale.value, |
| 157 | + useUTC: true, |
| 158 | + options: props.datetimeFormatterOptions, |
| 159 | + }, |
| 160 | + }, |
| 161 | + line: { |
| 162 | + color: seriesColor, |
| 163 | + dashIndices, |
| 164 | + dashArray: 3, |
| 165 | + }, |
| 166 | + plot: { |
| 167 | + radius: 6, |
| 168 | + stroke: isDarkMode.value ? 'oklch(0.985 0 0)' : 'oklch(0.145 0 0)', |
| 169 | + }, |
| 170 | + title: { |
| 171 | + fontSize: 12, |
| 172 | + color: colors.value.fgSubtle, |
| 173 | + bold: false, |
| 174 | + }, |
| 175 | +
|
| 176 | + verticalIndicator: { |
| 177 | + strokeDasharray: 0, |
| 178 | + color: colors.value.fgSubtle, |
| 179 | + }, |
| 180 | + padding: { |
| 181 | + left: 0, |
| 182 | + right: 0, |
| 183 | + top: 0, |
| 184 | + bottom: 0, |
| 185 | + }, |
| 186 | + }, |
| 187 | + } |
| 188 | + }) |
| 189 | +}) |
| 190 | +</script> |
| 191 | + |
| 192 | +<template> |
| 193 | + <div class="grid gap-8 sm:grid-cols-2"> |
| 194 | + <ClientOnly v-for="(config, i) in configs" :key="`config_${i}`"> |
| 195 | + <div @mouseleave="resetHover" @keydown.esc="resetHover" class="w-full max-w-[400px] mx-auto"> |
| 196 | + <div class="flex gap-2 place-items-center"> |
| 197 | + <div class="h-3 w-3"> |
| 198 | + <svg viewBox="0 0 2 2" class="w-full"> |
| 199 | + <rect |
| 200 | + x="0" |
| 201 | + y="0" |
| 202 | + width="2" |
| 203 | + height="2" |
| 204 | + rx="0.3" |
| 205 | + :fill="dataset?.[i]?.color ?? palette[i]" |
| 206 | + /> |
| 207 | + </svg> |
| 208 | + </div> |
| 209 | + {{ applyEllipsis(dataset?.[i]?.name ?? '', 28) }} |
| 210 | + </div> |
| 211 | + <VueUiSparkline |
| 212 | + :key="`${i}_${step}`" |
| 213 | + :config |
| 214 | + :dataset="datasets?.[i]" |
| 215 | + :selectedIndex |
| 216 | + @hoverIndex="hoverIndex" |
| 217 | + > |
| 218 | + <!-- Keyboard navigation hint --> |
| 219 | + <template #hint="{ isVisible }"> |
| 220 | + <p v-if="isVisible" class="text-accent text-xs text-center mt-2" aria-hidden="true"> |
| 221 | + {{ $t('package.downloads.sparkline_nav_hint') }} |
| 222 | + </p> |
| 223 | + </template> |
| 224 | + |
| 225 | + <template #skeleton> |
| 226 | + <!-- This empty div overrides the default built-in scanning animation on load --> |
| 227 | + <div /> |
| 228 | + </template> |
| 229 | + </VueUiSparkline> |
| 230 | + </div> |
| 231 | + |
| 232 | + <template #fallback> |
| 233 | + <!-- Skeleton matching VueUiSparkline layout (title 24px + SVG aspect 500:80) --> |
| 234 | + <div class="max-w-xs"> |
| 235 | + <!-- Title row: fontSize * 2 = 24px --> |
| 236 | + <div class="h-6 flex items-center ps-3"> |
| 237 | + <SkeletonInline class="h-3 w-36" /> |
| 238 | + </div> |
| 239 | + <!-- Chart area: matches SVG viewBox 500:80 --> |
| 240 | + <div class="aspect-[500/80] flex items-center"> |
| 241 | + <!-- Data label (covers ~42% width, matching dataLabel.offsetX) --> |
| 242 | + <div class="w-[42%] flex items-center ps-0.5"> |
| 243 | + <SkeletonInline class="h-7 w-24" /> |
| 244 | + </div> |
| 245 | + <!-- Sparkline line placeholder --> |
| 246 | + <div class="flex-1 flex items-end pe-3"> |
| 247 | + <SkeletonInline class="h-px w-full" /> |
| 248 | + </div> |
| 249 | + </div> |
| 250 | + </div> |
| 251 | + </template> |
| 252 | + </ClientOnly> |
| 253 | + </div> |
| 254 | +</template> |
0 commit comments