[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: apply color scheme to charts (#197)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Alec Lloyd Probert
Daniel Roe
and committed by
GitHub
f09077a3 762f2188

+224 -61
+57 -19
app/components/PackageDownloadAnalytics.vue
··· 16 16 createdIso: string | null 17 17 }>() 18 18 19 + const { accentColors, selectedAccentColor } = useAccentColor() 20 + const colorMode = useColorMode() 21 + 22 + const resolvedMode = ref<'light' | 'dark'>('light') 23 + 24 + onMounted(() => { 25 + resolvedMode.value = colorMode.value === 'dark' ? 'dark' : 'light' 26 + }) 27 + 28 + watch( 29 + () => colorMode.value, 30 + value => { 31 + resolvedMode.value = value === 'dark' ? 'dark' : 'light' 32 + }, 33 + { flush: 'sync' }, 34 + ) 35 + 36 + const isDarkMode = computed(() => resolvedMode.value === 'dark') 37 + 38 + // oklh or css variables are not supported by vue-data-ui (for now) 39 + 40 + const accentColorValueById = computed<Record<string, string>>(() => { 41 + const map: Record<string, string> = {} 42 + for (const item of accentColors) { 43 + map[item.id] = item.value 44 + } 45 + return map 46 + }) 47 + 48 + const accent = computed(() => { 49 + const id = selectedAccentColor.value 50 + return id ? (oklchToHex(accentColorValueById.value[id]!) ?? '#8A8A8A') : '#8A8A8A' 51 + }) 52 + 19 53 type ChartTimeGranularity = 'daily' | 'weekly' | 'monthly' | 'yearly' 20 54 type EvolutionData = 21 55 | DailyDownloadPoint[] ··· 81 115 name: packageName, 82 116 type: 'line', 83 117 series: dataset.map(d => d.downloads), 84 - color: '#8A8A8A', 118 + color: accent.value, 85 119 }, 86 120 ], 87 121 dates: dataset.map(d => `${d.weekStart}\nto ${d.weekEnd}`), ··· 94 128 name: packageName, 95 129 type: 'line', 96 130 series: dataset.map(d => d.downloads), 97 - color: '#8A8A8A', 131 + color: accent.value, 98 132 }, 99 133 ], 100 134 dates: dataset.map(d => d.day), ··· 107 141 name: packageName, 108 142 type: 'line', 109 143 series: dataset.map(d => d.downloads), 110 - color: '#8A8A8A', 144 + color: accent.value, 111 145 }, 112 146 ], 113 147 dates: dataset.map(d => d.month), ··· 120 154 name: packageName, 121 155 type: 'line', 122 156 series: dataset.map(d => d.downloads), 123 - color: '#8A8A8A', 157 + color: accent.value, 124 158 }, 125 159 ], 126 160 dates: dataset.map(d => d.year), ··· 381 415 const formatter = ({ value }: { value: number }) => formatCompactNumber(value, { decimals: 1 }) 382 416 383 417 const config = computed(() => ({ 384 - theme: 'dark', 418 + theme: isDarkMode.value ? 'dark' : 'default', 385 419 chart: { 386 420 userOptions: { 387 421 buttons: { ··· 392 426 tooltip: false, 393 427 }, 394 428 }, 395 - backgroundColor: '#0A0A0A', // current default dark mode theme, 429 + backgroundColor: isDarkMode.value ? '#0A0A0A' : '#FFFFFF', 396 430 grid: { 431 + stroke: isDarkMode.value ? '#4A4A4A' : '#a3a3a3', 397 432 labels: { 398 433 axis: { 399 434 yLabel: $t('package.downloads.y_axis_label', { granularity: selectedGranularity.value }), ··· 419 454 show: false, // As long as a single package is displayed 420 455 }, 421 456 tooltip: { 422 - borderColor: '#2A2A2A', 457 + borderColor: 'transparent', 423 458 backdropFilter: false, 424 459 backgroundColor: 'transparent', 425 460 customFormat: ({ ··· 431 466 }) => { 432 467 if (!datapoint) return '' 433 468 const displayValue = formatter({ value: datapoint[0]?.value ?? 0 }) 434 - return `<div class="flex flex-col font-mono text-xs p-3 bg-[#0A0A0A]/10 backdrop-blur-md"> 469 + return `<div class="flex flex-col font-mono text-xs p-3 border border-border rounded-md bg-white/10 dark:bg-[#0A0A0A]/10 backdrop-blur-md"> 435 470 <span class="text-fg-subtle">${chartData.value?.dates[absoluteIndex]}</span> 436 471 <span class="text-xl">${displayValue}</span> 437 472 </div> ··· 439 474 }, 440 475 }, 441 476 zoom: { 442 - highlightColor: '#2A2A2A', 477 + highlightColor: isDarkMode.value ? '#2A2A2A' : '#E1E5E8', 443 478 minimap: { 444 479 show: true, 445 480 lineColor: '#FAFAFA', 446 - selectedColorOpacity: 0.1, 447 - frameColor: '#3A3A3A', 481 + selectedColor: accent.value, 482 + selectedColorOpacity: 0.06, 483 + frameColor: isDarkMode.value ? '#3A3A3A' : '#a3a3a3', 448 484 }, 449 485 preview: { 450 - fill: '#FAFAFA05', 486 + fill: accent.value + 10, 487 + stroke: accent.value + 60, 451 488 strokeWidth: 1, 452 489 strokeDasharray: 3, 453 490 }, ··· 471 508 </label> 472 509 473 510 <div 474 - class="flex items-center px-2.5 py-1.75 bg-bg-subtle border border-border rounded-md focus-within:(border-border-hover ring-2 ring-fg/50)" 511 + class="flex items-center px-2.5 py-1.75 bg-bg-subtle border border-border rounded-md focus-within:(border-border-hover ring-2 ring-accent/30)" 475 512 > 476 513 <select 477 514 id="granularity" 478 515 v-model="selectedGranularity" 479 - class="w-full bg-transparent font-mono text-sm text-fg outline-none" 516 + class="w-full bg-transparent font-mono text-sm text-fg outline-none appearance-none" 480 517 > 481 518 <option value="daily">{{ $t('package.downloads.granularity_daily') }}</option> 482 519 <option value="weekly">{{ $t('package.downloads.granularity_weekly') }}</option> ··· 496 533 {{ $t('package.downloads.start_date') }} 497 534 </label> 498 535 <div 499 - class="flex items-center gap-2 px-2.5 py-1.75 bg-bg-subtle border border-border rounded-md focus-within:(border-border-hover ring-2 ring-fg/50)" 536 + class="flex items-center gap-2 px-2.5 py-1.75 bg-bg-subtle border border-border rounded-md focus-within:(border-border-hover ring-2 ring-accent/30)" 500 537 > 501 538 <span class="i-carbon-calendar w-4 h-4 text-fg-subtle shrink-0" aria-hidden="true" /> 502 539 <input ··· 516 553 {{ $t('package.downloads.end_date') }} 517 554 </label> 518 555 <div 519 - class="flex items-center gap-2 px-2.5 py-1.75 bg-bg-subtle border border-border rounded-md focus-within:(border-border-hover ring-2 ring-fg/50)" 556 + class="flex items-center gap-2 px-2.5 py-1.75 bg-bg-subtle border border-border rounded-md focus-within:(border-border-hover ring-2 ring-accent/30)" 520 557 > 521 558 <span class="i-carbon-calendar w-4 h-4 text-fg-subtle shrink-0" aria-hidden="true" /> 522 559 <input ··· 650 687 651 688 <style> 652 689 .vue-ui-pen-and-paper-actions { 653 - background: #1a1a1a !important; 690 + background: var(--bg-elevated) !important; 654 691 } 655 692 656 693 .vue-ui-pen-and-paper-action { 657 - background: #1a1a1a !important; 694 + background: var(--bg-elevated) !important; 658 695 border: none !important; 659 696 } 660 697 661 698 .vue-ui-pen-and-paper-action:hover { 662 - background: #2a2a2a !important; 699 + background: var(--bg-elevated) !important; 700 + box-shadow: none !important; 663 701 } 664 702 665 703 .vue-data-ui-zoom {
+85 -42
app/components/PackageWeeklyDownloadStats.vue
··· 13 13 14 14 const { fetchPackageDownloadEvolution } = useCharts() 15 15 16 + const { accentColors, selectedAccentColor } = useAccentColor() 17 + 18 + const colorMode = useColorMode() 19 + 20 + const resolvedMode = ref<'light' | 'dark'>('light') 21 + 22 + onMounted(() => { 23 + resolvedMode.value = colorMode.value === 'dark' ? 'dark' : 'light' 24 + }) 25 + 26 + watch( 27 + () => colorMode.value, 28 + value => { 29 + resolvedMode.value = value === 'dark' ? 'dark' : 'light' 30 + }, 31 + { flush: 'sync' }, 32 + ) 33 + 34 + const isDarkMode = computed(() => resolvedMode.value === 'dark') 35 + 36 + const accentColorValueById = computed<Record<string, string>>(() => { 37 + const map: Record<string, string> = {} 38 + for (const item of accentColors) { 39 + map[item.id] = item.value 40 + } 41 + return map 42 + }) 43 + 44 + const accent = computed(() => { 45 + const id = selectedAccentColor.value 46 + return id ? (oklchToHex(accentColorValueById.value[id]!) ?? '#8A8A8A') : '#8A8A8A' 47 + }) 48 + 49 + const pulseColor = computed(() => { 50 + if (!selectedAccentColor.value) { 51 + return isDarkMode.value ? '#BFBFBF' : '#E0E0E0' 52 + } 53 + return isDarkMode.value ? accent.value : lightenHex(accent.value, 0.5) 54 + }) 55 + 16 56 const weeklyDownloads = ref<WeeklyDownloadPoint[]>([]) 17 57 18 58 async function loadWeeklyDownloads() { ··· 51 91 52 92 const lastDatapoint = computed(() => dataset.value.at(-1)?.period ?? '') 53 93 54 - const config = computed(() => ({ 55 - theme: 'dark', 56 - style: { 57 - backgroundColor: 'transparent', 58 - animation: { show: false }, 59 - area: { 60 - color: 'oklch(0.5243 0 0)', // css variable doesn't seem to work here 61 - useGradient: false, 62 - opacity: 10, 63 - }, 64 - dataLabel: { 65 - offsetX: -10, 66 - fontSize: 28, 67 - bold: false, 68 - color: 'var(--fg)', 69 - }, 70 - line: { 71 - color: 'var(--fg-subtle)', 72 - pulse: { 73 - show: true, 74 - loop: true, // runs only once if false 75 - radius: 2, 76 - color: 'var(--fg-muted)', 77 - easing: 'ease-in-out', 78 - trail: { 94 + // oklh or css variables are not supported by vue-data-ui (for now) 95 + const config = computed(() => { 96 + return { 97 + theme: 'dark', 98 + style: { 99 + backgroundColor: 'transparent', 100 + animation: { show: false }, 101 + area: { 102 + color: '#6A6A6A', 103 + useGradient: false, 104 + opacity: 10, 105 + }, 106 + dataLabel: { 107 + offsetX: -10, 108 + fontSize: 28, 109 + bold: false, 110 + color: isDarkMode.value ? '#8a8a8a' : '#696969', 111 + }, 112 + line: { 113 + color: isDarkMode.value ? '#4a4a4a' : '#525252', 114 + pulse: { 79 115 show: true, 80 - length: 6, 116 + loop: true, // runs only once if false 117 + radius: 2, 118 + color: pulseColor.value, 119 + easing: 'ease-in-out', 120 + trail: { 121 + show: true, 122 + length: 6, 123 + }, 81 124 }, 82 125 }, 126 + plot: { 127 + radius: 6, 128 + stroke: isDarkMode.value ? '#FAFAFA' : '#0A0A0A', 129 + }, 130 + title: { 131 + text: lastDatapoint.value, 132 + fontSize: 12, 133 + color: isDarkMode.value ? '#8a8a8a' : '#696969', 134 + bold: false, 135 + }, 136 + verticalIndicator: { 137 + strokeDasharray: 0, 138 + color: isDarkMode.value ? '#FAFAFA' : '#525252', 139 + }, 83 140 }, 84 - plot: { 85 - radius: 6, 86 - stroke: 'var(--fg)', 87 - }, 88 - title: { 89 - text: lastDatapoint.value, 90 - fontSize: 12, 91 - color: 'var(--fg)', 92 - bold: false, 93 - }, 94 - verticalIndicator: { 95 - strokeDasharray: 0, 96 - color: 'var(--fg-muted)', 97 - }, 98 - }, 99 - })) 141 + } 142 + }) 100 143 </script> 101 144 102 145 <template>
+82
app/utils/colors.ts
··· 1 + // Vue Data UI does not support CSS vars nor OKLCH for now 2 + 3 + /** 4 + * Converts a hex color to RGB components 5 + */ 6 + function hexToRgb(hex: string): [number, number, number] | null { 7 + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) 8 + return result 9 + ? [parseInt(result[1]!, 16), parseInt(result[2]!, 16), parseInt(result[3]!, 16)] 10 + : null 11 + } 12 + 13 + /** 14 + * Converts RGB components to hex color 15 + */ 16 + function rgbToHex(r: number, g: number, b: number): string { 17 + const toHex = (value: number): string => 18 + Math.round(Math.min(Math.max(0, value), 255)) 19 + .toString(16) 20 + .padStart(2, '0') 21 + return `#${toHex(r)}${toHex(g)}${toHex(b)}` 22 + } 23 + 24 + /** 25 + * Lightens a hex color by mixing it with white. 26 + * Used to create light tints of accent colors for better visibility in light mode. 27 + * @param hex - The hex color to lighten (e.g., "#ff0000") 28 + * @param factor - Lighten factor from 0 to 1 (0.5 = 50% lighter, mixed with white) 29 + */ 30 + export function lightenHex(hex: string, factor: number = 0.5): string { 31 + const rgb = hexToRgb(hex) 32 + if (!rgb) return hex 33 + 34 + // Lighten by mixing with white (255, 255, 255) 35 + const lightened = rgb.map(c => Math.round(c + (255 - c) * factor)) as [number, number, number] 36 + return rgbToHex(...lightened) 37 + } 38 + 39 + export function oklchToHex(color: string | undefined | null): string | undefined | null { 40 + if (color == null) return color 41 + 42 + const match = color.trim().match(/^oklch\(\s*([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\s*\)$/i) 43 + 44 + if (!match) { 45 + throw new Error('Invalid OKLCH color format') 46 + } 47 + 48 + const lightness = Number(match[1]) 49 + const chroma = Number(match[2]) 50 + const hue = Number(match[3]) 51 + 52 + const hRad = (hue * Math.PI) / 180 53 + 54 + const a = chroma * Math.cos(hRad) 55 + const b = chroma * Math.sin(hRad) 56 + 57 + let l_ = lightness + 0.3963377774 * a + 0.2158037573 * b 58 + let m_ = lightness - 0.1055613458 * a - 0.0638541728 * b 59 + let s_ = lightness - 0.0894841775 * a - 1.291485548 * b 60 + 61 + l_ = l_ ** 3 62 + m_ = m_ ** 3 63 + s_ = s_ ** 3 64 + 65 + let r = 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_ 66 + let g = -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_ 67 + let bRgb = -0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_ 68 + 69 + const toSrgb = (value: number): number => 70 + value <= 0.0031308 ? 12.92 * value : 1.055 * Math.pow(value, 1 / 2.4) - 0.055 71 + 72 + r = toSrgb(r) 73 + g = toSrgb(g) 74 + bRgb = toSrgb(bRgb) 75 + 76 + const toHex = (value: number): string => 77 + Math.round(Math.min(Math.max(0, value), 1) * 255) 78 + .toString(16) 79 + .padStart(2, '0') 80 + 81 + return `#${toHex(r)}${toHex(g)}${toHex(bRgb)}` 82 + }