|
import React, { useState, useEffect } from 'react'; |
|
import { |
|
LineChart, |
|
Line, |
|
XAxis, |
|
YAxis, |
|
CartesianGrid, |
|
Tooltip, |
|
Legend, |
|
ResponsiveContainer |
|
} from 'recharts'; |
|
import { PROVIDERS_MAP } from '../../utils/providers'; |
|
import { |
|
fetchAllModelData, |
|
generateMonthlyData, |
|
getTotalMonthlyData, |
|
processDetailedModelData, |
|
MonthlyActivity, |
|
DetailedModelData, |
|
convertToCSV |
|
} from '../../utils/modelData'; |
|
import Link from 'next/link'; |
|
|
|
interface TrendProps { |
|
monthlyData: MonthlyActivity[]; |
|
totalData: MonthlyActivity[]; |
|
detailedData: DetailedModelData[]; |
|
error?: string; |
|
} |
|
|
|
const COLORS = Object.fromEntries( |
|
Object.entries(PROVIDERS_MAP).map(([provider, { color }]) => [provider, color]) |
|
); |
|
COLORS['Total'] = '#4CAF50'; |
|
|
|
const CustomTooltip = ({ active, payload, label }: any) => { |
|
if (active && payload && payload.length) { |
|
return ( |
|
<div className="bg-white p-4 border border-gray-200 rounded shadow dark:bg-gray-800 dark:border-gray-700"> |
|
<p className="text-sm font-bold mb-2 dark:text-white">{label}</p> |
|
{payload.map((entry: any) => ( |
|
<p |
|
key={entry.name} |
|
className="text-sm" |
|
style={{ color: entry.color }} |
|
> |
|
{entry.name}: {entry.value} models |
|
</p> |
|
))} |
|
</div> |
|
); |
|
} |
|
return null; |
|
}; |
|
|
|
const formatDate = (dateStr: string) => { |
|
const date = new Date(dateStr); |
|
return date.toLocaleDateString('en-US', { |
|
year: 'numeric', |
|
month: 'short', |
|
day: 'numeric' |
|
}); |
|
}; |
|
|
|
const TrendPage: React.FC<TrendProps> = ({ monthlyData = [], totalData = [], detailedData = [], error }) => { |
|
const [showTotal, setShowTotal] = useState(true); |
|
const [selectedProviders, setSelectedProviders] = useState<string[]>([]); |
|
const [minLikes, setMinLikes] = useState(100); |
|
const [isLoading, setIsLoading] = useState(false); |
|
const [contentType, setContentType] = useState<'all' | 'models' | 'datasets'>('all'); |
|
|
|
const toggleProvider = (provider: string) => { |
|
setSelectedProviders(prev => |
|
prev.includes(provider) |
|
? prev.filter(p => p !== provider) |
|
: [...prev, provider] |
|
); |
|
}; |
|
|
|
|
|
const filteredTotalData = totalData.filter(d => { |
|
if (contentType === 'all') { |
|
return d.isDataset === null; |
|
} |
|
return (contentType === 'datasets' && d.isDataset === true) || |
|
(contentType === 'models' && d.isDataset === false); |
|
}); |
|
|
|
|
|
const providerData = Object.fromEntries( |
|
Object.keys(PROVIDERS_MAP).map(provider => { |
|
const providerMonthlyData = monthlyData.filter(d => { |
|
if (contentType === 'all') { |
|
return d.provider === provider; |
|
} |
|
const matchesContentType = (contentType === 'datasets' && d.isDataset === true) || |
|
(contentType === 'models' && d.isDataset === false); |
|
return d.provider === provider && matchesContentType; |
|
}); |
|
|
|
|
|
if (contentType === 'all') { |
|
const aggregatedData: Record<string, MonthlyActivity> = {}; |
|
providerMonthlyData.forEach(d => { |
|
if (!aggregatedData[d.date]) { |
|
aggregatedData[d.date] = { |
|
date: d.date, |
|
count: 0, |
|
provider: d.provider, |
|
isDataset: null |
|
}; |
|
} |
|
aggregatedData[d.date].count += d.count; |
|
}); |
|
return [provider, Object.values(aggregatedData).sort((a, b) => a.date.localeCompare(b.date))]; |
|
} |
|
|
|
return [provider, providerMonthlyData || []]; |
|
}) |
|
); |
|
|
|
|
|
const filteredModels = (detailedData || []) |
|
.filter(model => { |
|
const matchesContentType = contentType === 'all' || |
|
(contentType === 'datasets' && model.isDataset) || |
|
(contentType === 'models' && !model.isDataset); |
|
|
|
return model.likes >= minLikes && |
|
(selectedProviders.length === 0 || selectedProviders.includes(model.provider)) && |
|
matchesContentType; |
|
}) |
|
.reduce<Record<string, DetailedModelData[]>>((acc, model) => { |
|
if (!acc[model.monthKey]) { |
|
acc[model.monthKey] = []; |
|
} |
|
acc[model.monthKey].push(model); |
|
return acc; |
|
}, {}); |
|
|
|
useEffect(() => { |
|
console.log('Content type changed:', contentType); |
|
console.log('Selected providers:', selectedProviders); |
|
console.log('Min likes:', minLikes); |
|
}, [contentType, selectedProviders, minLikes]); |
|
|
|
if (error) { |
|
return ( |
|
<div className="w-full max-w-screen-xl mx-auto p-4"> |
|
<h1 className="text-3xl lg:text-5xl mt-16 font-bold text-center mb-2 dark:text-white">Chinese AI Model Release Trends 📈</h1> |
|
<div className="text-center text-red-600 dark:text-red-400 mt-8"> |
|
<p>Error loading data: {error}</p> |
|
<p className="mt-2">Please try again later.</p> |
|
</div> |
|
</div> |
|
); |
|
} |
|
|
|
return ( |
|
<div className="w-full max-w-screen-lg mx-auto p-4"> |
|
<div className="flex justify-between items-center mb-8"> |
|
<div> |
|
<h1 className="text-3xl lg:text-4xl font-bold mb-2">Chinese AI Open Source Trends 🤗</h1> |
|
<p className="text-gray-600 dark:text-gray-400"> |
|
Track the growth of Chinese AI models and datasets over time |
|
</p> |
|
</div> |
|
<Link |
|
href="/heatmap" |
|
className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors" |
|
> |
|
View Heatmap |
|
</Link> |
|
</div> |
|
<p className="text-center text-sm mb-8 dark:text-gray-300"> |
|
Track the growth of Chinese AI models and datasets over time |
|
</p> |
|
|
|
<div className="flex flex-col gap-4 p-4"> |
|
<div className="flex justify-center mb-6 space-x-4"> |
|
<button |
|
onClick={() => setContentType('all')} |
|
className={`px-4 py-2 rounded-lg ${ |
|
contentType === 'all' |
|
? 'bg-blue-500 text-white' |
|
: 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300' |
|
}`} |
|
> |
|
All |
|
</button> |
|
<button |
|
onClick={() => setContentType('models')} |
|
className={`px-4 py-2 rounded-lg ${ |
|
contentType === 'models' |
|
? 'bg-blue-500 text-white' |
|
: 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300' |
|
}`} |
|
> |
|
Models |
|
</button> |
|
<button |
|
onClick={() => setContentType('datasets')} |
|
className={`px-4 py-2 rounded-lg ${ |
|
contentType === 'datasets' |
|
? 'bg-blue-500 text-white' |
|
: 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300' |
|
}`} |
|
> |
|
Datasets |
|
</button> |
|
</div> |
|
|
|
<div className="flex flex-wrap gap-4 mb-6 justify-center items-center"> |
|
<div className="flex gap-2"> |
|
<button |
|
onClick={() => setShowTotal(!showTotal)} |
|
className={`px-4 py-2 rounded-lg ${ |
|
showTotal |
|
? 'bg-blue-500 text-white' |
|
: 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300' |
|
}`} |
|
> |
|
{showTotal ? 'Hide Total' : 'Show Total'} |
|
</button> |
|
{Object.entries(PROVIDERS_MAP).map(([provider, { color }]) => ( |
|
<button |
|
key={provider} |
|
onClick={() => toggleProvider(provider)} |
|
className={`px-4 py-2 rounded-lg transition-colors ${ |
|
selectedProviders.includes(provider) |
|
? 'text-white' |
|
: 'text-gray-600 dark:text-gray-400' |
|
}`} |
|
style={{ |
|
backgroundColor: selectedProviders.includes(provider) |
|
? color |
|
: 'transparent' |
|
}} |
|
> |
|
{provider} |
|
</button> |
|
))} |
|
</div> |
|
</div> |
|
|
|
{/* Chart */} |
|
<div className="w-full h-[600px] dark:bg-gray-900 p-4 rounded-lg"> |
|
<ResponsiveContainer width="100%" height="100%"> |
|
<LineChart |
|
margin={{ top: 20, right: 30, left: 20, bottom: 20 }} |
|
> |
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" /> |
|
<XAxis |
|
dataKey="date" |
|
type="category" |
|
allowDuplicatedCategory={false} |
|
tick={{ fontSize: 12 }} |
|
interval="preserveStartEnd" |
|
stroke="#9CA3AF" |
|
/> |
|
<YAxis stroke="#9CA3AF" /> |
|
<Tooltip content={<CustomTooltip />} /> |
|
<Legend /> |
|
|
|
{/* Total Line */} |
|
{showTotal && ( |
|
<Line |
|
data={filteredTotalData} |
|
type="monotone" |
|
dataKey="count" |
|
stroke={COLORS['Total']} |
|
name="Total" |
|
strokeWidth={2} |
|
dot={false} |
|
/> |
|
)} |
|
|
|
{/* Provider Lines */} |
|
{selectedProviders.map(provider => ( |
|
<Line |
|
key={provider} |
|
data={providerData[provider]} |
|
type="monotone" |
|
dataKey="count" |
|
stroke={COLORS[provider]} |
|
name={provider} |
|
strokeWidth={1.5} |
|
dot={false} |
|
/> |
|
))} |
|
</LineChart> |
|
</ResponsiveContainer> |
|
</div> |
|
|
|
{/* Download Button */} |
|
<div className="flex justify-center mt-4 mb-8"> |
|
<button |
|
className="px-4 py-2 rounded-lg bg-green-500 text-white hover:bg-green-600" |
|
onClick={() => { |
|
// Get the currently visible data based on filters |
|
const visibleData = []; |
|
|
|
// Add provider data if showing |
|
selectedProviders.forEach(provider => { |
|
visibleData.push(...providerData[provider].map(d => ({ ...d, provider }))); |
|
}); |
|
|
|
// Add total data if showing |
|
if (showTotal) { |
|
visibleData.push(...filteredTotalData); |
|
} |
|
|
|
const csvContent = convertToCSV(visibleData); |
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); |
|
const link = document.createElement('a'); |
|
link.href = URL.createObjectURL(blob); |
|
link.download = `${contentType}_counts.csv`; |
|
link.click(); |
|
URL.revokeObjectURL(link.href); |
|
}} |
|
> |
|
Download CSV |
|
</button> |
|
</div> |
|
|
|
{/* Major Releases Section */} |
|
<div className="mt-12"> |
|
<div className="flex items-center justify-between mb-6"> |
|
<h2 className="text-2xl font-bold dark:text-white"> |
|
Major Releases ({Object.values(filteredModels).flat().length}) |
|
</h2> |
|
<div className="flex items-center gap-3 bg-white dark:bg-gray-800 px-4 py-2 rounded-lg shadow-sm"> |
|
<label className="text-sm font-medium dark:text-gray-300">Min Likes:</label> |
|
<input |
|
type="number" |
|
value={minLikes} |
|
onChange={(e) => setMinLikes(Math.max(0, parseInt(e.target.value) || 0))} |
|
className="w-20 px-2 py-1 border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white focus:outline-none focus:ring-2 focus:ring-green-500" |
|
/> |
|
</div> |
|
</div> |
|
<div className="space-y-8"> |
|
{Object.entries(filteredModels) |
|
.map(([monthKey, models]) => ({ |
|
monthKey, |
|
models, |
|
sortKey: models[0]?.sortKey || '' // Use the first model's sortKey |
|
})) |
|
.sort((a, b) => b.sortKey.localeCompare(a.sortKey)) // Sort by sortKey in descending order |
|
.map(({ monthKey, models }) => ( |
|
<div key={monthKey} className="border-b border-gray-200 dark:border-gray-700 pb-4"> |
|
<h3 className="text-xl font-semibold mb-3 dark:text-white">{monthKey}</h3> |
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> |
|
{models |
|
.sort((a, b) => b.likes - a.likes) // Sort models within each month by likes |
|
.map(model => ( |
|
<div |
|
key={model.id} |
|
className="p-4 rounded-lg border dark:border-gray-700 hover:shadow-md transition-shadow bg-white dark:bg-gray-800" |
|
style={{ borderColor: COLORS[model.provider] }} |
|
> |
|
<div className="flex justify-between items-start"> |
|
<div> |
|
<a |
|
href={`https://huggingface.co/${model.id}`} |
|
target="_blank" |
|
rel="noopener noreferrer" |
|
className="text-blue-600 hover:underline font-medium dark:text-blue-400" |
|
> |
|
{model.name} |
|
</a> |
|
<p className="text-sm text-gray-600 dark:text-gray-400">{model.provider}</p> |
|
</div> |
|
<div className="text-right"> |
|
<p className="text-sm font-medium dark:text-white">❤️ {model.likes}</p> |
|
<p className="text-xs text-gray-500 dark:text-gray-400">{formatDate(model.createdAt)}</p> |
|
</div> |
|
</div> |
|
</div> |
|
))} |
|
</div> |
|
</div> |
|
))} |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
); |
|
}; |
|
|
|
export const getStaticProps = async () => { |
|
try { |
|
const modelData = await fetchAllModelData(); |
|
const monthlyData = generateMonthlyData(modelData); |
|
const totalData = getTotalMonthlyData(monthlyData); |
|
const filteredModels = processDetailedModelData(modelData); |
|
|
|
return { |
|
props: { |
|
monthlyData, |
|
totalData, |
|
filteredModels, |
|
}, |
|
|
|
revalidate: 43200, |
|
}; |
|
} catch (error) { |
|
console.error('Error in getStaticProps:', error); |
|
return { |
|
props: { |
|
monthlyData: [], |
|
totalData: [], |
|
filteredModels: {}, |
|
}, |
|
|
|
revalidate: 3600, |
|
}; |
|
} |
|
}; |
|
|
|
export default TrendPage; |
|
|