@@ -15,12 +15,36 @@ const props = withDefaults(
1515 },
1616)
1717
18- const ACCENT_COLORS = [' #60a5fa' , ' #f472b6' , ' #34d399' , ' #fbbf24' ]
18+ // OG image is 1200×600. With 80px horizontal padding, content width is 1040px.
19+ const OG_PADDING_X = 80
20+ const CONTENT_WIDTH = 1200 - OG_PADDING_X * 2
21+
22+ const ACCENT_COLORS = [
23+ ' #60a5fa' , ' #f472b6' , ' #34d399' , ' #fbbf24' ,
24+ ' #a78bfa' , ' #fb923c' , ' #22d3ee' , ' #e879f9' ,
25+ ' #4ade80' , ' #f87171' , ' #38bdf8' , ' #facc15' ,
26+ ]
27+
28+ // Tier thresholds
29+ const FULL_MAX = 4
30+ const COMPACT_MAX = 6
31+ const GRID_MAX = 12
32+ const SUMMARY_TOP_COUNT = 3
1933
2034const displayPackages = computed (() => {
2135 const raw = props .packages
22- const list = (typeof raw === ' string' ? raw .split (' ,' ) : raw ).map (p => p .trim ()).filter (Boolean )
23- return list .slice (0 , 4 )
36+ return (typeof raw === ' string' ? raw .split (' ,' ) : raw )
37+ .map (p => p .trim ())
38+ .filter (Boolean )
39+ })
40+
41+ type LayoutTier = ' full' | ' compact' | ' grid' | ' summary'
42+ const layoutTier = computed <LayoutTier >(() => {
43+ const count = displayPackages .value .length
44+ if (count <= FULL_MAX ) return ' full'
45+ if (count <= COMPACT_MAX ) return ' compact'
46+ if (count <= GRID_MAX ) return ' grid'
47+ return ' summary'
2448})
2549
2650interface PkgStats {
@@ -34,36 +58,41 @@ const stats = ref<PkgStats[]>([])
3458
3559const FETCH_TIMEOUT_MS = 2500
3660
37- try {
38- const results = await Promise .all (
39- displayPackages .value .map (async (name , index ) => {
40- const encoded = encodePackageName (name )
41- const [dlData, pkgData] = await Promise .all ([
42- $fetch <{ downloads: number }>(
43- ` https://api.npmjs.org/downloads/point/last-week/${encoded } ` ,
44- { timeout: FETCH_TIMEOUT_MS },
45- ).catch (() => null ),
46- $fetch <{ ' dist-tags' ? : { latest? : string } }>(` https://registry.npmjs.org/${encoded } ` , {
47- timeout: FETCH_TIMEOUT_MS ,
48- headers: { Accept: ' application/vnd.npm.install-v1+json' },
49- }).catch (() => null ),
50- ])
51- return {
52- name ,
53- downloads: dlData ?.downloads ?? 0 ,
54- version: pkgData ?.[' dist-tags' ]?.latest ?? ' ' ,
55- color: ACCENT_COLORS [index % ACCENT_COLORS .length ]! ,
56- }
57- }),
58- )
59- stats .value = results
60- } catch {
61- stats .value = displayPackages .value .map ((name , index ) => ({
62- name ,
63- downloads: 0 ,
64- version: ' ' ,
65- color: ACCENT_COLORS [index % ACCENT_COLORS .length ]! ,
66- }))
61+ if (layoutTier .value !== ' summary' ) {
62+ try {
63+ const results = await Promise .all (
64+ displayPackages .value .map (async (name , index ) => {
65+ const encoded = encodePackageName (name )
66+ const needsVersion = layoutTier .value === ' full'
67+ const [dlData, pkgData] = await Promise .all ([
68+ $fetch <{ downloads: number }>(
69+ ` https://api.npmjs.org/downloads/point/last-week/${encoded } ` ,
70+ { timeout: FETCH_TIMEOUT_MS },
71+ ).catch (() => null ),
72+ needsVersion
73+ ? $fetch <{ ' dist-tags' ? : { latest? : string } }>(` https://registry.npmjs.org/${encoded } ` , {
74+ timeout: FETCH_TIMEOUT_MS ,
75+ headers: { Accept: ' application/vnd.npm.install-v1+json' },
76+ }).catch (() => null )
77+ : Promise .resolve (null ),
78+ ])
79+ return {
80+ name ,
81+ downloads: dlData ?.downloads ?? 0 ,
82+ version: pkgData ?.[' dist-tags' ]?.latest ?? ' ' ,
83+ color: ACCENT_COLORS [index % ACCENT_COLORS .length ]! ,
84+ }
85+ }),
86+ )
87+ stats .value = results
88+ } catch {
89+ stats .value = displayPackages .value .map ((name , index ) => ({
90+ name ,
91+ downloads: 0 ,
92+ version: ' ' ,
93+ color: ACCENT_COLORS [index % ACCENT_COLORS .length ]! ,
94+ }))
95+ }
6796}
6897
6998const maxDownloads = computed (() => Math .max (... stats .value .map (s => s .downloads ), 1 ))
@@ -76,17 +105,44 @@ function formatDownloads(n: number): string {
76105 }).format (n )
77106}
78107
79- // Bar width as percentage string (max 100%)
108+ const BAR_MIN_PCT = 5
109+ const BAR_MAX_PCT = 100
110+
80111function barPct(downloads : number ): string {
81112 if (downloads <= 0 ) return ' 0%'
82113 const pct = (downloads / maxDownloads .value ) * 100
83- return ` ${Math .min (100 , Math .max (pct , 5 ))}% `
114+ return ` ${Math .min (BAR_MAX_PCT , Math .max (pct , BAR_MIN_PCT ))}% `
84115}
116+
117+ // Grid layout: aim for 2 balanced rows
118+ const GRID_COLS_SMALL = 4 // for up to 8 packages (2 rows of 4)
119+ const GRID_COLS_LARGE = 5 // for 9+ packages (2 rows of 5)
120+
121+ const gridColumns = computed (() => {
122+ const count = stats .value .length
123+ if (count <= GRID_COLS_SMALL * 2 ) return GRID_COLS_SMALL
124+ return GRID_COLS_LARGE
125+ })
126+
127+ const GRID_ITEM_GAP = 10
128+ const gridItemWidth = computed (() => ` ${Math .floor (CONTENT_WIDTH / gridColumns .value ) - GRID_ITEM_GAP }px ` )
129+
130+ const gridRows = computed (() => {
131+ const cols = gridColumns .value
132+ const rows: PkgStats [][] = []
133+ for (let i = 0 ; i < stats .value .length ; i += cols ) {
134+ rows .push (stats .value .slice (i , i + cols ))
135+ }
136+ return rows
137+ })
138+
139+ const summaryTopNames = computed (() => displayPackages .value .slice (0 , SUMMARY_TOP_COUNT ))
140+ const summaryRemainder = computed (() => Math .max (0 , displayPackages .value .length - SUMMARY_TOP_COUNT ))
85141 </script >
86142
87143<template >
88144 <div
89- class =" h-full w-full flex flex-col justify-center px-20 bg-[#050505] text-[#fafafa] relative overflow-hidden "
145+ class =" h-full w-full flex flex-col justify-center relative overflow-hidden bg-[#050505] text-[#fafafa] px-20 "
90146 style =" font-family : ' Geist Mono' , sans-serif "
91147 >
92148 <div class =" relative z-10 flex flex-col gap-5" >
@@ -125,17 +181,16 @@ function barPct(downloads: number): string {
125181
126182 <!-- Empty state -->
127183 <div
128- v-if =" stats .length === 0"
184+ v-if =" displayPackages .length === 0"
129185 class =" text-4xl text-[#a3a3a3]"
130186 style =" font-family : ' Geist' , sans-serif "
131187 >
132188 {{ emptyDescription }}
133189 </div >
134190
135- <!-- Bar chart rows -->
136- <div v-else class =" flex flex-col gap-2" >
191+ <!-- FULL layout (1-4 packages): name + downloads + version badge + bar -->
192+ <div v-else-if = " layoutTier === 'full' " class =" flex flex-col gap-2" >
137193 <div v-for =" pkg in stats" :key =" pkg.name" class =" flex flex-col gap-1" >
138- <!-- Label row: name + downloads + version -->
139194 <div class =" flex items-center gap-3" style =" font-family : ' Geist' , sans-serif " >
140195 <span
141196 class =" text-2xl font-semibold tracking-tight truncate max-w-[400px]"
@@ -158,8 +213,6 @@ function barPct(downloads: number): string {
158213 {{ pkg.version }}
159214 </span >
160215 </div >
161-
162- <!-- Bar -->
163216 <div
164217 class =" h-6 rounded-md"
165218 :style =" {
@@ -169,6 +222,86 @@ function barPct(downloads: number): string {
169222 />
170223 </div >
171224 </div >
225+
226+ <!-- COMPACT layout (5-6 packages): name + downloads + thinner bar, no version -->
227+ <div v-else-if =" layoutTier === 'compact'" class =" flex flex-col gap-2" >
228+ <div v-for =" pkg in stats" :key =" pkg.name" class =" flex flex-col gap-0.5" >
229+ <div class =" flex items-center gap-2" style =" font-family : ' Geist' , sans-serif " >
230+ <span
231+ class =" text-xl font-semibold tracking-tight truncate max-w-[300px]"
232+ :style =" { color: pkg.color }"
233+ >
234+ {{ pkg.name }}
235+ </span >
236+ <span class =" text-xl font-bold text-[#fafafa]" >
237+ {{ formatDownloads(pkg.downloads) }}/wk
238+ </span >
239+ </div >
240+ <div
241+ class =" h-3 rounded-sm"
242+ :style =" {
243+ width: barPct(pkg.downloads),
244+ background: `linear-gradient(90deg, ${pkg.color}50, ${pkg.color}20)`,
245+ }"
246+ />
247+ </div >
248+ </div >
249+
250+ <!-- GRID layout (7-12 packages): packages in a side-by-side grid -->
251+ <div v-else-if =" layoutTier === 'grid'" class =" flex flex-col gap-6" style =" font-family : ' Geist' , sans-serif " >
252+ <div
253+ v-for =" (row, ri) in gridRows"
254+ :key =" ri"
255+ class =" flex items-start"
256+ >
257+ <!-- Using <span> as grid items because Satori treats <div> as full-width flex columns -->
258+ <span
259+ v-for =" pkg in row"
260+ :key =" pkg.name"
261+ :style =" {
262+ display: 'flex',
263+ flexDirection: 'column',
264+ gap: '2px',
265+ width: gridItemWidth,
266+ }"
267+ >
268+ <span
269+ class =" font-semibold tracking-tight"
270+ :style =" {
271+ fontSize: '18px',
272+ overflow: 'hidden',
273+ textOverflow: 'ellipsis',
274+ whiteSpace: 'nowrap',
275+ color: pkg.color,
276+ }"
277+ >{{ pkg.name }}</span >
278+ <span class =" flex items-baseline gap-0.5" >
279+ <span class =" text-2xl font-bold text-[#e5e5e5]" >{{ formatDownloads(pkg.downloads) }}</span >
280+ <span class =" text-sm font-medium text-[#737373]" >/wk</span >
281+ </span >
282+ </span >
283+ </div >
284+ </div >
285+
286+ <!-- SUMMARY layout (13+ packages): top names + remainder count -->
287+ <div v-else class =" flex flex-col gap-4" style =" font-family : ' Geist' , sans-serif " >
288+ <div class =" text-5xl font-bold tracking-tight" >
289+ Comparing {{ displayPackages.length }} packages
290+ </div >
291+ <div class =" flex items-center gap-2 text-2xl text-[#a3a3a3]" >
292+ <span
293+ v-for =" (name, i) in summaryTopNames"
294+ :key =" name"
295+ class =" font-semibold"
296+ :style =" { color: ACCENT_COLORS[i % ACCENT_COLORS.length] }"
297+ >
298+ {{ name }}<span v-if =" i < summaryTopNames.length - 1" class =" text-[#525252]" >,</span >
299+ </span >
300+ <span v-if =" summaryRemainder > 0" class =" text-[#737373]" >
301+ +{{ summaryRemainder }} more
302+ </span >
303+ </div >
304+ </div >
172305 </div >
173306
174307 <div
0 commit comments