ludocomito's picture
init
d328b13
"use client"
import { useState, useMemo, useEffect, useRef } from "react"
import { Search, ExternalLink, Mail, FileText, X, ChevronDown } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { DatasetStatistics } from "@/components/dataset-statistics"
import { ScrollToTop } from "@/components/scroll-to-top"
interface Dataset {
Name: string
Category: string
Description: string
Task: string
Data_Type: string
Source: string
"Paper link": string
Availability: string
Contact: string
}
function getCategoryColor(category: string): { bg: string; text: string; hover: string } {
const colors = [
{ bg: "bg-blue-100 dark:bg-blue-950", text: "text-blue-700 dark:text-blue-300", hover: "hover:bg-blue-200 dark:hover:bg-blue-900" },
{ bg: "bg-emerald-100 dark:bg-emerald-950", text: "text-emerald-700 dark:text-emerald-300", hover: "hover:bg-emerald-200 dark:hover:bg-emerald-900" },
{ bg: "bg-amber-100 dark:bg-amber-950", text: "text-amber-700 dark:text-amber-300", hover: "hover:bg-amber-200 dark:hover:bg-amber-900" },
{ bg: "bg-rose-100 dark:bg-rose-950", text: "text-rose-700 dark:text-rose-300", hover: "hover:bg-rose-200 dark:hover:bg-rose-900" },
{ bg: "bg-cyan-100 dark:bg-cyan-950", text: "text-cyan-700 dark:text-cyan-300", hover: "hover:bg-cyan-200 dark:hover:bg-cyan-900" },
]
const hash = category.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0)
return colors[hash % colors.length]
}
function getDataTypeColor(dataType: string): { bg: string; text: string; hover: string } {
const colors = [
{ bg: "bg-indigo-100 dark:bg-indigo-950", text: "text-indigo-700 dark:text-indigo-300", hover: "hover:bg-indigo-200 dark:hover:bg-indigo-900" },
{ bg: "bg-teal-100 dark:bg-teal-950", text: "text-teal-700 dark:text-teal-300", hover: "hover:bg-teal-200 dark:hover:bg-teal-900" },
{ bg: "bg-orange-100 dark:bg-orange-950", text: "text-orange-700 dark:text-orange-300", hover: "hover:bg-orange-200 dark:hover:bg-orange-900" },
{ bg: "bg-pink-100 dark:bg-pink-950", text: "text-pink-700 dark:text-pink-300", hover: "hover:bg-pink-200 dark:hover:bg-pink-900" },
{ bg: "bg-lime-100 dark:bg-lime-950", text: "text-lime-700 dark:text-lime-300", hover: "hover:bg-lime-200 dark:hover:bg-lime-900" },
{ bg: "bg-sky-100 dark:bg-sky-950", text: "text-sky-700 dark:text-sky-300", hover: "hover:bg-sky-200 dark:hover:bg-sky-900" },
]
const hash = dataType.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0)
return colors[hash % colors.length]
}
function getCategoryHoverClasses(category: string): string {
const sets = [
{ bg: "hover:!bg-blue-50 dark:hover:!bg-blue-950/40", text: "hover:!text-blue-700 dark:hover:!text-blue-300" },
{ bg: "hover:!bg-indigo-50 dark:hover:!bg-indigo-950/40", text: "hover:!text-indigo-700 dark:hover:!text-indigo-300" },
{ bg: "hover:!bg-violet-50 dark:hover:!bg-violet-950/40", text: "hover:!text-violet-700 dark:hover:!text-violet-300" },
{ bg: "hover:!bg-emerald-50 dark:hover:!bg-emerald-950/40", text: "hover:!text-emerald-700 dark:hover:!text-emerald-300" },
{ bg: "hover:!bg-amber-50 dark:hover:!bg-amber-950/40", text: "hover:!text-amber-700 dark:hover:!text-amber-300" },
{ bg: "hover:!bg-rose-50 dark:hover:!bg-rose-950/40", text: "hover:!text-rose-700 dark:hover:!text-rose-300" },
] as const
const hash = category.split("").reduce((acc, ch) => acc + ch.charCodeAt(0), 0)
const c = sets[hash % sets.length]
return `${c.bg} ${c.text}`
}
/* ---------- Share-link + CSV helpers ---------- */
function buildShareUrl(opts: {
searchQuery: string
selectedCategory: string
selectedTasks: string[]
selectedDataTypes: string[]
}) {
const u = new URL(window.location.href)
const p = u.searchParams
p.set("q", opts.searchQuery || "")
p.set("cat", opts.selectedCategory || "all")
p.set("tasks", opts.selectedTasks.join(","))
p.set("types", opts.selectedDataTypes.join(","))
u.search = p.toString()
return u.toString()
}
function downloadCSV(rows: any[], filename = "datasets_filtered.csv") {
if (!rows.length) {
alert("Nothing to export — adjust your filters first.")
return
}
const headers = Object.keys(rows[0])
const esc = (v: any) => `"${String(v ?? "").replace(/"/g, '""')}"`
const csv = [headers.join(","), ...rows.map(r => headers.map(h => esc(r[h])).join(","))].join("\n")
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
export function DatasetAtlas() {
const [datasets, setDatasets] = useState<Dataset[]>([])
const [searchQuery, setSearchQuery] = useState("")
const [selectedCategory, setSelectedCategory] = useState<string>("all")
const [selectedTasks, setSelectedTasks] = useState<string[]>([])
const [selectedDataTypes, setSelectedDataTypes] = useState<string[]>([])
const [loading, setLoading] = useState(true)
const [selectedDataset, setSelectedDataset] = useState<Dataset | null>(null)
const taskTriggerRef = useRef<HTMLButtonElement | null>(null)
const dataTypeTriggerRef = useRef<HTMLButtonElement | null>(null)
const [taskMenuWidth, setTaskMenuWidth] = useState<number | null>(null)
const [dataTypeMenuWidth, setDataTypeMenuWidth] = useState<number | null>(null)
// Chart filters passed to <DatasetStatistics />
const [chartFilterCategory, setChartFilterCategory] = useState<string | null>(null)
const [chartFilterDataType, setChartFilterDataType] = useState<string | null>(null)
const [chartFilterAvailability, setChartFilterAvailability] = useState<string | null>(null)
useEffect(() => {
async function fetchData() {
try {
const response = await fetch(
"https://blobs.vusercontent.net/blob/Awesome%20food%20allergy%20datasets%20-%20Copia%20di%20Full%20view%20%281%29-nbq61oXBgltNRiIJIq8djoMoqX7ItK.tsv",
)
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
const text = await response.text()
const parsed = parseTSV(text)
setDatasets(parsed)
} catch (error) {
console.error("[v0] Error fetching datasets:", error)
} finally {
setLoading(false)
}
}
fetchData()
}, [])
useEffect(() => {
function updateWidths() {
if (taskTriggerRef.current) setTaskMenuWidth(Math.round(taskTriggerRef.current.getBoundingClientRect().width))
if (dataTypeTriggerRef.current) setDataTypeMenuWidth(Math.round(dataTypeTriggerRef.current.getBoundingClientRect().width))
}
updateWidths()
window.addEventListener("resize", updateWidths)
return () => window.removeEventListener("resize", updateWidths)
}, [])
function parseTSV(text: string): Dataset[] {
const lines = text.split("\n")
const headers = lines[0].split("\t").map((h) => h.trim().replace(/^"|"$/g, ""))
return lines
.slice(1)
.filter((line) => line.trim())
.map((line) => {
const values = line.split("\t").map((v) => v.trim())
const dataset: any = {}
headers.forEach((header, index) => (dataset[header] = values[index] || ""))
return dataset as Dataset
})
}
const categories = useMemo(() => {
const cats = new Set(datasets.map((d) => d.Category).filter(Boolean))
return ["all", ...Array.from(cats).sort()]
}, [datasets])
const tasks = useMemo(() => {
const set = new Set<string>()
datasets.forEach((d) => {
if (d.Task) d.Task.split(",").map((t) => t.trim()).forEach((t) => t && set.add(t))
})
return Array.from(set).sort()
}, [datasets])
const dataTypes = useMemo(() => {
const set = new Set(datasets.map((d) => d.Data_Type).filter(Boolean))
return Array.from(set).sort()
}, [datasets])
const filteredDatasets = useMemo(() => {
return datasets.filter((d) => {
const q = searchQuery.toLowerCase()
const matchesSearch =
d.Name?.toLowerCase().includes(q) ||
d.Description?.toLowerCase().includes(q) ||
d.Category?.toLowerCase().includes(q) ||
d.Task?.toLowerCase().includes(q) ||
d.Data_Type?.toLowerCase().includes(q)
const matchesCategory = selectedCategory === "all" || d.Category === selectedCategory
const matchesTask =
selectedTasks.length === 0 ||
d.Task.split(",").map((t) => t.trim()).some((t) => selectedTasks.includes(t))
const matchesDataType = selectedDataTypes.length === 0 || selectedDataTypes.includes(d.Data_Type)
const matchesChartCategory = !chartFilterCategory || d.Category === chartFilterCategory
const matchesChartDataType = !chartFilterDataType || d.Data_Type === chartFilterDataType
const matchesChartAvailability = !chartFilterAvailability || d.Availability === chartFilterAvailability
return matchesSearch && matchesCategory && matchesTask && matchesDataType &&
matchesChartCategory && matchesChartDataType && matchesChartAvailability
})
}, [datasets, searchQuery, selectedCategory, selectedTasks, selectedDataTypes, chartFilterCategory, chartFilterDataType, chartFilterAvailability])
const toggleTask = (task: string) =>
setSelectedTasks((prev) => (prev.includes(task) ? prev.filter((t) => t !== task) : [...prev, task]))
const removeTask = (task: string) => setSelectedTasks((prev) => prev.filter((t) => t !== task))
const clearAllTasks = () => setSelectedTasks([])
const toggleDataType = (dataType: string) =>
setSelectedDataTypes((prev) => (prev.includes(dataType) ? prev.filter((t) => t !== dataType) : [...prev, dataType]))
const removeDataType = (dataType: string) => setSelectedDataTypes((prev) => prev.filter((t) => t !== dataType))
const clearAllDataTypes = () => setSelectedDataTypes([])
const handleCategoryChartClick = (category: string) =>
setChartFilterCategory((prev) => (prev === category ? null : category))
const handleDataTypeChartClick = (dataType: string) =>
setChartFilterDataType((prev) => (prev === dataType ? null : dataType))
const handleAvailabilityChartClick = (availability: string) =>
setChartFilterAvailability((prev) => (prev === availability ? null : availability))
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b border-border bg-card">
<div className="container mx-auto px-4 py-6 md:py-8">
<h1 className="text-3xl md:text-4xl font-bold text-balance mb-2">Awesome Food Allergy Research Datasets</h1>
<p className="text-muted-foreground text-lg text-pretty">
A curated collection of datasets for advancing food allergy research
</p>
</div>
</header>
<div className="container mx-auto px-4 py-8">
{/* Search and Filters */}
<div className="mb-6 space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<Input
type="text"
placeholder="Search datasets by name, description, category, task, or data type..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 h-12 text-base"
/>
</div>
{/* --- Actions bar (Share + Export) — right under search --- */}
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
const url = buildShareUrl({
searchQuery,
selectedCategory,
selectedTasks,
selectedDataTypes,
})
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(url)
.then(() => alert("Shareable link copied to clipboard!"))
.catch(() => prompt("Copy this link:", url))
} else {
prompt("Copy this link:", url)
}
}}
>
Share this view (copy link)
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const rows = filteredDatasets.map(d => ({
Name: d.Name,
Category: d.Category,
"Data Type": d.Data_Type,
Task: d.Task,
Availability: d.Availability,
"Access URL": d.Source,
"Paper URL": d["Paper link"],
Contact: d.Contact,
Description: d.Description,
}))
downloadCSV(rows)
}}
>
Export current list (CSV)
</Button>
</div>
<div className="flex flex-col gap-4 pt-2">
<div>
<label className="text-sm font-medium mb-2 block">Filter by Category</label>
<div className="flex flex-wrap gap-2">
{categories.map((category) => (
<Button
key={category}
variant={selectedCategory === category ? "default" : "outline"}
size="sm"
onClick={() => setSelectedCategory(category)}
className={`capitalize transition-colors ${getCategoryHoverClasses(category)}`}
>
{category}
</Button>
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium mb-2 block">Filter by Task</label>
<div className="space-y-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button ref={taskTriggerRef} variant="outline" className="w-full justify-between bg-transparent">
<span>
{selectedTasks.length === 0
? "Select tasks..."
: `${selectedTasks.length} task${selectedTasks.length > 1 ? "s" : ""} selected`}
</span>
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-[300px] overflow-y-auto" style={{ width: taskMenuWidth ?? undefined }}>
{tasks.map((task) => (
<DropdownMenuCheckboxItem key={task} checked={selectedTasks.includes(task)} onCheckedChange={() => toggleTask(task)}>
{task}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{selectedTasks.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedTasks.map((task) => (
<Badge key={task} variant="secondary" className="pl-2 pr-1 py-1 text-xs shadow-none">
{task}
<button onClick={() => removeTask(task)} className="ml-1 hover:bg-muted rounded-full p-0.5">
<X className="h-3 w-3" />
</button>
</Badge>
))}
<Button variant="ghost" size="sm" onClick={clearAllTasks} className="h-6 px-2 text-xs">
Clear all
</Button>
</div>
)}
</div>
</div>
<div>
<label className="text-sm font-medium mb-2 block">Filter by Data Type</label>
<div className="space-y-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button ref={dataTypeTriggerRef} variant="outline" className="w-full justify-between bg-transparent">
<span>
{selectedDataTypes.length === 0
? "Select data types..."
: `${selectedDataTypes.length} type${selectedDataTypes.length > 1 ? "s" : ""} selected`}
</span>
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-[300px] overflow-y-auto" style={{ width: dataTypeMenuWidth ?? undefined }}>
{dataTypes.map((dataType) => (
<DropdownMenuCheckboxItem key={dataType} checked={selectedDataTypes.includes(dataType)} onCheckedChange={() => toggleDataType(dataType)}>
{dataType}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{selectedDataTypes.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedDataTypes.map((dataType) => (
<Badge key={dataType} variant="secondary" className="pl-2 pr-1 py-1 text-xs shadow-none">
{dataType}
<button onClick={() => removeDataType(dataType)} className="ml-1 hover:bg-muted rounded-full p-0.5">
<X className="h-3 w-3" />
</button>
</Badge>
))}
<Button variant="ghost" size="sm" onClick={clearAllDataTypes} className="h-6 px-2 text-xs">
Clear all
</Button>
</div>
)}
</div>
</div>
</div>
</div>
</div>
{/* Statistics Charts */}
{!loading && datasets.length > 0 && (
<DatasetStatistics
datasets={datasets}
selectedCategory={chartFilterCategory}
selectedDataType={chartFilterDataType}
selectedAvailability={chartFilterAvailability}
onCategoryClick={handleCategoryChartClick}
onDataTypeClick={handleDataTypeChartClick}
onAvailabilityClick={handleAvailabilityChartClick}
/>
)}
{/* Results Count */}
<div className="mb-6">
<p className="text-sm text-muted-foreground">
Showing <span className="font-semibold text-foreground">{filteredDatasets.length}</span> of{" "}
<span className="font-semibold text-foreground">{datasets.length}</span> datasets
</p>
</div>
{/* Dataset Grid */}
{loading ? (
<div className="text-center py-12">
<p className="text-muted-foreground">Loading datasets...</p>
</div>
) : filteredDatasets.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">No datasets found matching your criteria.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredDatasets.map((dataset, index) => {
const categoryColors = getCategoryColor(dataset.Category)
const dataTypeColors = getDataTypeColor(dataset.Data_Type)
return (
<Card key={index} className="flex flex-col hover:shadow-lg transition-shadow cursor-pointer h-[480px]" onClick={() => setSelectedDataset(dataset)}>
<CardHeader className="pb-3">
<div className="flex gap-2 mb-3 flex-wrap">
{dataset.Category && (
<Badge className={`text-xs border-0 shadow-none ${categoryColors.bg} ${categoryColors.text} ${categoryColors.hover}`}>
{dataset.Category}
</Badge>
)}
{dataset.Data_Type && (
<Badge className={`text-xs border-0 shadow-none ${dataTypeColors.bg} ${dataTypeColors.text} ${dataTypeColors.hover}`}>
{dataset.Data_Type}
</Badge>
)}
{dataset.Availability && (
<Badge variant={dataset.Availability.toLowerCase().includes("open") ? "default" : "outline"} className="text-xs shadow-none">
{dataset.Availability}
</Badge>
)}
</div>
<CardTitle className="text-xl text-balance leading-tight">{dataset.Name}</CardTitle>
{dataset.Task && (
<CardDescription className="text-sm font-medium line-clamp-2">
Task: {dataset.Task}
</CardDescription>
)}
</CardHeader>
<CardContent className="flex-1 pb-3">
<p className="text-sm text-muted-foreground text-pretty leading-relaxed line-clamp-4">
{dataset.Description}
</p>
</CardContent>
<CardFooter className="flex flex-col gap-2 items-stretch pt-3 pb-5">
{dataset.Source && (
<Button variant="default" size="sm" asChild className="w-full" onClick={(e) => e.stopPropagation()}>
<a href={dataset.Source} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4 mr-2" />
Access Dataset
</a>
</Button>
)}
<div className="flex gap-2">
{dataset["Paper link"] && (
<Button variant="outline" size="sm" asChild className="flex-1 bg-transparent" onClick={(e) => e.stopPropagation()}>
<a href={dataset["Paper link"]} target="_blank" rel="noopener noreferrer">
<FileText className="h-4 w-4 mr-2" />
Paper
</a>
</Button>
)}
{dataset.Contact && (
<Button variant="outline" size="sm" asChild className="flex-1 bg-transparent" onClick={(e) => e.stopPropagation()}>
<a href={`mailto:${dataset.Contact}`}>
<Mail className="h-4 w-4 mr-2" />
Contact
</a>
</Button>
)}
</div>
</CardFooter>
</Card>
)
})}
</div>
)}
</div>
{/* Modal Dialog for Expanded Dataset View */}
<Dialog open={!!selectedDataset} onOpenChange={(open) => !open && setSelectedDataset(null)}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
{selectedDataset && (
<>
<DialogHeader>
<div className="flex gap-2 mb-3 flex-wrap">
{selectedDataset.Category && (
<Badge className={`text-xs border-0 shadow-none ${getCategoryColor(selectedDataset.Category).bg} ${getCategoryColor(selectedDataset.Category).text} ${getCategoryColor(selectedDataset.Category).hover}`}>
{selectedDataset.Category}
</Badge>
)}
{selectedDataset.Data_Type && (
<Badge className={`text-xs border-0 shadow-none ${getDataTypeColor(selectedDataset.Data_Type).bg} ${getDataTypeColor(selectedDataset.Data_Type).text} ${getDataTypeColor(selectedDataset.Data_Type).hover}`}>
{selectedDataset.Data_Type}
</Badge>
)}
{selectedDataset.Availability && (
<Badge variant={selectedDataset.Availability.toLowerCase().includes("open") ? "default" : "outline"} className="text-xs shadow-none">
{selectedDataset.Availability}
</Badge>
)}
</div>
<DialogTitle className="text-2xl text-balance">{selectedDataset.Name}</DialogTitle>
{selectedDataset.Task && (
<DialogDescription className="text-base font-medium text-foreground">
Task: {selectedDataset.Task}
</DialogDescription>
)}
</DialogHeader>
<div className="space-y-6 mt-4">
<div>
<h3 className="font-semibold mb-2 text-sm uppercase tracking-wide text-muted-foreground">Description</h3>
<p className="text-sm leading-relaxed">{selectedDataset.Description}</p>
</div>
<div className="flex flex-col gap-3">
{selectedDataset.Source && (
<Button variant="default" size="default" asChild className="w-full">
<a href={selectedDataset.Source} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4 mr-2" />
Access Dataset
</a>
</Button>
)}
<div className="flex gap-3">
{selectedDataset["Paper link"] && (
<Button variant="outline" size="default" asChild className="flex-1 bg-transparent">
<a href={selectedDataset["Paper link"]} target="_blank" rel="noopener noreferrer">
<FileText className="h-4 w-4 mr-2" />
Read Paper
</a>
</Button>
)}
{selectedDataset.Contact && (
<Button variant="outline" size="default" asChild className="flex-1 bg-transparent">
<a href={`mailto:${selectedDataset.Contact}`}>
<Mail className="h-4 w-4 mr-2" />
Contact
</a>
</Button>
)}
</div>
</div>
</div>
</>
)}
</DialogContent>
</Dialog>
<ScrollToTop />
</div>
)
}