Skip to content

Commit a31e4a4

Browse files
junior1pclaude
andcommitted
Add DEG Interpreter tool
- Add new card on tools page for DEG Interpreter - Create deg-interpreter page with file upload and volcano plot - Implement CSV/TSV parser with flexible column name detection - Add FileUploader component for drag-and-drop file upload - Add VolcanoPlot component using Plotly.js for interactive visualization - Include TF annotation and unannotated gene prediction - Support species selection (Human/Mouse) - Add TF data files for mouse and human - Support column aliases: log2FC/log2_fold_change/lfc, pvalue/p_value, padj/FDR - Export functionality for significant genes and top genes tables - Add QC summary display with statistics Dependencies: - react-plotly.js: Interactive plotting library - plotly.js-basic-dist-min: Plotly core library - @types/react-plotly.js: TypeScript definitions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
1 parent 0772ade commit a31e4a4

9 files changed

Lines changed: 3919 additions & 14 deletions

File tree

app/tools/deg-interpreter/page.tsx

Lines changed: 501 additions & 0 deletions
Large diffs are not rendered by default.

app/tools/page.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ export default function ToolsPage() {
2222
path: '/tools/mmcif-to-pdb',
2323
icon: '🔄',
2424
},
25+
{
26+
title: 'DEG Interpreter',
27+
description: '上传 DEG 表 → QC + 火山图 + Top 基因(支持 CSV/TSV)',
28+
path: '/tools/deg-interpreter',
29+
icon: '📊',
30+
},
2531
];
2632

2733
return (
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
'use client';
2+
3+
import { useCallback, useState } from 'react';
4+
5+
interface FileUploaderProps {
6+
onFileLoad: (content: string, fileName: string) => void;
7+
acceptedFormats?: string[];
8+
}
9+
10+
export default function FileUploader({ onFileLoad, acceptedFormats = ['.csv', '.tsv', '.txt'] }: FileUploaderProps) {
11+
const [dragActive, setDragActive] = useState(false);
12+
const [fileName, setFileName] = useState<string | null>(null);
13+
const [rowCount, setRowCount] = useState<number | null>(null);
14+
const [error, setError] = useState<string | null>(null);
15+
16+
const handleDrag = useCallback((e: React.DragEvent) => {
17+
e.preventDefault();
18+
e.stopPropagation();
19+
if (e.type === 'dragenter' || e.type === 'dragover') {
20+
setDragActive(true);
21+
} else if (e.type === 'dragleave') {
22+
setDragActive(false);
23+
}
24+
}, []);
25+
26+
const handleDrop = useCallback((e: React.DragEvent) => {
27+
e.preventDefault();
28+
e.stopPropagation();
29+
setDragActive(false);
30+
31+
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
32+
processFile(e.dataTransfer.files[0]);
33+
}
34+
}, []);
35+
36+
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
37+
if (e.target.files && e.target.files[0]) {
38+
processFile(e.target.files[0]);
39+
}
40+
}, []);
41+
42+
const processFile = async (file: File) => {
43+
setError(null);
44+
45+
// Check file size
46+
if (file.size > 50 * 1024 * 1024) {
47+
setError('文件过大(>50MB),处理可能较慢,请耐心等待');
48+
// Continue anyway
49+
}
50+
51+
try {
52+
const content = await file.text();
53+
const lines = content.split('\n').filter(line => line.trim());
54+
55+
if (lines.length < 2) {
56+
setError('文件为空或格式不正确');
57+
return;
58+
}
59+
60+
setFileName(file.name);
61+
setRowCount(lines.length - 1); // Exclude header
62+
onFileLoad(content, file.name);
63+
} catch (err) {
64+
setError(err instanceof Error ? err.message : '文件读取失败');
65+
}
66+
};
67+
68+
const handleClear = () => {
69+
setFileName(null);
70+
setRowCount(null);
71+
setError(null);
72+
};
73+
74+
return (
75+
<div className="glass rounded-lg p-6">
76+
<div
77+
className={`relative border-2 border-dashed rounded-lg p-12 text-center transition-colors ${
78+
dragActive
79+
? 'border-cyan-400 bg-cyan-500/10'
80+
: 'border-gray-600 hover:border-gray-500'
81+
}`}
82+
onDragEnter={handleDrag}
83+
onDragLeave={handleDrag}
84+
onDragOver={handleDrag}
85+
onDrop={handleDrop}
86+
>
87+
<input
88+
type="file"
89+
accept={acceptedFormats.join(',')}
90+
onChange={handleChange}
91+
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
92+
/>
93+
94+
<div className="text-5xl mb-4">📁</div>
95+
96+
{fileName ? (
97+
<div>
98+
<p className="text-lg font-medium text-white mb-2">
99+
已加载: {fileName}
100+
</p>
101+
<p className="text-sm text-gray-400 mb-4">
102+
{rowCount} 行数据
103+
</p>
104+
<button
105+
onClick={(e) => {
106+
e.stopPropagation();
107+
handleClear();
108+
}}
109+
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors text-sm"
110+
>
111+
清除文件
112+
</button>
113+
</div>
114+
) : (
115+
<div>
116+
<p className="text-lg font-medium text-white mb-2">
117+
点击或拖拽文件到此处上传
118+
</p>
119+
<p className="text-sm text-gray-400">
120+
支持 CSV/TSV 格式(自动识别分隔符)
121+
</p>
122+
</div>
123+
)}
124+
</div>
125+
126+
{error && (
127+
<div className="mt-4 p-4 bg-red-500/10 border border-red-500/50 rounded-lg">
128+
<p className="text-red-400 text-sm">{error}</p>
129+
</div>
130+
)}
131+
</div>
132+
);
133+
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
'use client';
2+
3+
import { useMemo } from 'react';
4+
import dynamic from 'next/dynamic';
5+
6+
// Dynamic import Plotly to avoid SSR issues
7+
const Plot = dynamic(() => import('react-plotly.js'), {
8+
ssr: false,
9+
loading: () => (
10+
<div className="w-full h-[500px] flex items-center justify-center bg-slate-800/50 rounded-lg">
11+
<div className="text-center">
12+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-cyan-400 mx-auto mb-4"></div>
13+
<p className="text-gray-400">加载图表中...</p>
14+
</div>
15+
</div>
16+
),
17+
});
18+
19+
interface DEGRow {
20+
gene: string;
21+
log2FC: number;
22+
pvalue: number;
23+
padj?: number;
24+
}
25+
26+
interface VolcanoPlotProps {
27+
data: DEGRow[];
28+
log2FCThreshold: number;
29+
pThreshold: number;
30+
usePadj: boolean;
31+
}
32+
33+
export default function VolcanoPlot({ data, log2FCThreshold, pThreshold, usePadj }: VolcanoPlotProps) {
34+
35+
const plotData = useMemo(() => {
36+
// Calculate significance for each point
37+
const points = data.map((row) => {
38+
const pValue = usePadj && row.padj !== undefined ? row.padj : row.pvalue;
39+
const negLog10P = -Math.log10(pValue);
40+
const log2FC = row.log2FC;
41+
42+
let category: 'up' | 'down' | 'not';
43+
if (Math.abs(log2FC) >= log2FCThreshold && pValue < pThreshold) {
44+
category = log2FC > 0 ? 'up' : 'down';
45+
} else {
46+
category = 'not';
47+
}
48+
49+
return {
50+
x: log2FC,
51+
y: negLog10P,
52+
gene: row.gene,
53+
log2FC,
54+
pvalue: pValue,
55+
category,
56+
};
57+
});
58+
59+
// Separate by category
60+
const up = points.filter((p) => p.category === 'up');
61+
const down = points.filter((p) => p.category === 'down');
62+
const not = points.filter((p) => p.category === 'not');
63+
64+
// Calculate threshold line
65+
const thresholdY = -Math.log10(pThreshold);
66+
67+
return {
68+
traces: [
69+
{
70+
x: up.map((p) => p.x),
71+
y: up.map((p) => p.y),
72+
text: up.map((p) => `${p.gene}<br>log2FC: ${p.log2FC.toFixed(2)}<br>p: ${p.pvalue.toExponential(2)}`),
73+
mode: 'markers' as const,
74+
type: 'scatter' as const,
75+
name: 'Upregulated',
76+
marker: { color: '#ef4444', size: 4, opacity: 0.6 },
77+
},
78+
{
79+
x: down.map((p) => p.x),
80+
y: down.map((p) => p.y),
81+
text: down.map((p) => `${p.gene}<br>log2FC: ${p.log2FC.toFixed(2)}<br>p: ${p.pvalue.toExponential(2)}`),
82+
mode: 'markers' as const,
83+
type: 'scatter' as const,
84+
name: 'Downregulated',
85+
marker: { color: '#3b82f6', size: 4, opacity: 0.6 },
86+
},
87+
{
88+
x: not.map((p) => p.x),
89+
y: not.map((p) => p.y),
90+
text: not.map((p) => `${p.gene}<br>log2FC: ${p.log2FC.toFixed(2)}<br>p: ${p.pvalue.toExponential(2)}`),
91+
mode: 'markers' as const,
92+
type: 'scatter' as const,
93+
name: 'Not significant',
94+
marker: { color: '#6b7280', size: 3, opacity: 0.4 },
95+
},
96+
],
97+
shapes: [
98+
// Vertical lines (log2FC thresholds)
99+
{
100+
type: 'line' as const,
101+
x0: log2FCThreshold,
102+
y0: 0,
103+
x1: log2FCThreshold,
104+
y1: thresholdY * 1.2,
105+
line: { color: '#10b981', width: 2, dash: 'dash' as const },
106+
},
107+
{
108+
type: 'line' as const,
109+
x0: -log2FCThreshold,
110+
y0: 0,
111+
x1: -log2FCThreshold,
112+
y1: thresholdY * 1.2,
113+
line: { color: '#10b981', width: 2, dash: 'dash' as const },
114+
},
115+
// Horizontal line (p-value threshold)
116+
{
117+
type: 'line' as const,
118+
x0: Math.min(...points.map((p) => p.x)) - 1,
119+
y0: thresholdY,
120+
x1: Math.max(...points.map((p) => p.x)) + 1,
121+
y1: thresholdY,
122+
line: { color: '#10b981', width: 2, dash: 'dash' as const },
123+
},
124+
],
125+
annotations: [
126+
{
127+
x: log2FCThreshold,
128+
y: thresholdY * 1.15,
129+
text: `log2FC = ${log2FCThreshold}`,
130+
showarrow: false,
131+
font: { color: '#10b981', size: 10 },
132+
xanchor: 'left' as const,
133+
},
134+
{
135+
x: -log2FCThreshold,
136+
y: thresholdY * 1.15,
137+
text: `log2FC = -${log2FCThreshold}`,
138+
showarrow: false,
139+
font: { color: '#10b981', size: 10 },
140+
xanchor: 'right' as const,
141+
},
142+
{
143+
x: Math.max(...points.map((p) => p.x)) * 0.8,
144+
y: thresholdY * 1.05,
145+
text: `p${usePadj ? 'adj' : ''} = ${pThreshold}`,
146+
showarrow: false,
147+
font: { color: '#10b981', size: 10 },
148+
},
149+
],
150+
layout: {
151+
autosize: true,
152+
hovermode: 'closest' as const,
153+
paper_bgcolor: 'rgba(0,0,0,0)',
154+
plot_bgcolor: 'rgba(0,0,0,0)',
155+
font: { color: '#9ca3af' },
156+
margin: { l: 60, r: 20, t: 40, b: 50 },
157+
xaxis: {
158+
title: { text: 'log2 Fold Change' },
159+
gridcolor: '#374151',
160+
zerolinecolor: '#6b7280',
161+
},
162+
yaxis: {
163+
title: { text: `-log10(${usePadj ? 'adj ' : ''}p-value)` },
164+
gridcolor: '#374151',
165+
zerolinecolor: '#6b7280',
166+
},
167+
legend: {
168+
x: 0.02,
169+
y: 0.98,
170+
bgcolor: 'rgba(31, 41, 55, 0.8)',
171+
bordercolor: '#4b5563',
172+
borderwidth: 1,
173+
},
174+
modebar: {
175+
bgcolor: 'rgba(31, 41, 55, 0.8)',
176+
color: '#9ca3af',
177+
activecolor: '#10b981',
178+
},
179+
},
180+
};
181+
}, [data, log2FCThreshold, pThreshold, usePadj]);
182+
183+
return (
184+
<div className="glass rounded-lg p-6">
185+
<div className="flex items-center justify-between mb-4">
186+
<h2 className="text-xl font-semibold text-white">火山图 (Volcano Plot)</h2>
187+
<span className="text-sm text-gray-400">使用图表右上角的相机图标下载图片</span>
188+
</div>
189+
<div className="w-full" style={{ height: '500px' }}>
190+
<Plot
191+
data={plotData.traces}
192+
layout={{
193+
...plotData.layout,
194+
shapes: plotData.shapes,
195+
annotations: plotData.annotations,
196+
}}
197+
config={{
198+
responsive: true,
199+
displayModeBar: true,
200+
displaylogo: false,
201+
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
202+
toImageButtonOptions: {
203+
format: 'svg',
204+
filename: 'volcano_plot',
205+
height: 800,
206+
width: 1200,
207+
scale: 1,
208+
},
209+
}}
210+
style={{ width: '100%', height: '100%' }}
211+
useResizeHandler={true}
212+
/>
213+
</div>
214+
</div>
215+
);
216+
}

0 commit comments

Comments
 (0)