Skip to content

Commit db6b41e

Browse files
AmirMohammad CheraghaliAmirMohammad Cheraghali
authored andcommitted
feat: Enhanced Multi-Page PDF Report
1 parent 8e5c87a commit db6b41e

3 files changed

Lines changed: 188 additions & 137 deletions

File tree

src/components/ContactMap.tsx

Lines changed: 1 addition & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState, useMemo } from 'react';
22
import { X, ZoomIn, ZoomOut, Maximize, Download, Grid3X3, Check, FileText, Menu, BookOpen } from 'lucide-react';
33
import type { ChainInfo } from '../types';
44
import { generateProteinReport } from '../utils/pdfGenerator';
5+
import { getInteractionType } from '../utils/interactionUtils';
56

67
interface ContactMapProps {
78
isOpen: boolean;
@@ -13,48 +14,7 @@ interface ContactMapProps {
1314
proteinName?: string;
1415
}
1516

16-
const getInteractionType = (label1: string, label2: string, dist: number) => {
17-
if (dist > 8.0) return null;
1817

19-
// Extract residue name (assumes "ALA 123" format or similar, takes first word)
20-
const getResName = (l: string) => l.trim().split(' ')[0].toUpperCase();
21-
const r1 = getResName(label1);
22-
const r2 = getResName(label2);
23-
24-
const POSITIVE = ['ARG', 'LYS', 'HIS'];
25-
const NEGATIVE = ['ASP', 'GLU'];
26-
const AROMATIC = ['PHE', 'TYR', 'TRP', 'HIS'];
27-
const HYDROPHOBIC = ['ALA', 'VAL', 'ILE', 'LEU', 'MET', 'PHE', 'TYR', 'TRP', 'CYS', 'PRO'];
28-
29-
// 1. Disulfide (Specific)
30-
if (r1 === 'CYS' && r2 === 'CYS' && dist < 3.0) return { type: 'Disulfide Bond', color: 'text-yellow-500', bg: 'bg-yellow-500/10' };
31-
32-
// 2. Salt Bridge (Strong Electrostatic)
33-
const isP1 = POSITIVE.includes(r1);
34-
const isN1 = NEGATIVE.includes(r1);
35-
const isP2 = POSITIVE.includes(r2);
36-
const isN2 = NEGATIVE.includes(r2);
37-
38-
if ((isP1 && isN2) || (isN1 && isP2)) return { type: 'Salt Bridge', color: 'text-red-500', bg: 'bg-red-500/10' };
39-
40-
// 3. Cation-Pi
41-
const isA1 = AROMATIC.includes(r1);
42-
const isA2 = AROMATIC.includes(r2);
43-
if ((isP1 && isA2) || (isA1 && isP2)) return { type: 'Cation-Pi Interaction', color: 'text-indigo-500', bg: 'bg-indigo-500/10' };
44-
45-
// 4. Pi-Stacking
46-
if (isA1 && isA2) return { type: 'Pi-Stacking', color: 'text-purple-500', bg: 'bg-purple-500/10' };
47-
48-
// 5. Hydrophobic (Generic, check dist < 5.0 for meaningful core packing vs just 8.0 contact)
49-
const isH1 = HYDROPHOBIC.includes(r1);
50-
const isH2 = HYDROPHOBIC.includes(r2);
51-
if (isH1 && isH2 && dist < 5.0) return { type: 'Hydrophobic Contact', color: 'text-green-500', bg: 'bg-green-500/10' };
52-
53-
// Fallback for close contacts
54-
if (dist < 4.0) return { type: 'Close Contact', color: 'text-neutral-500', bg: 'bg-neutral-500/10' };
55-
56-
return null;
57-
};
5818

5919
export const ContactMap: React.FC<ContactMapProps> = ({
6020
isOpen,

src/utils/interactionUtils.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
export const getInteractionType = (label1: string, label2: string, dist: number) => {
2+
if (dist > 8.0) return null;
3+
4+
// Extract residue name (assumes "ALA 123" format or similar, takes first word)
5+
const getResName = (l: string) => l.trim().split(' ')[0].toUpperCase();
6+
const r1 = getResName(label1);
7+
const r2 = getResName(label2);
8+
9+
const POSITIVE = ['ARG', 'LYS', 'HIS'];
10+
const NEGATIVE = ['ASP', 'GLU'];
11+
const AROMATIC = ['PHE', 'TYR', 'TRP', 'HIS'];
12+
const HYDROPHOBIC = ['ALA', 'VAL', 'ILE', 'LEU', 'MET', 'PHE', 'TYR', 'TRP', 'CYS', 'PRO'];
13+
14+
// 1. Disulfide (Specific)
15+
if (r1 === 'CYS' && r2 === 'CYS' && dist < 3.0) return { type: 'Disulfide Bond', color: 'text-yellow-500', bg: 'bg-yellow-500/10', hex: '#eab308' };
16+
17+
// 2. Salt Bridge (Strong Electrostatic)
18+
const isP1 = POSITIVE.includes(r1);
19+
const isN1 = NEGATIVE.includes(r1);
20+
const isP2 = POSITIVE.includes(r2);
21+
const isN2 = NEGATIVE.includes(r2);
22+
23+
if ((isP1 && isN2) || (isN1 && isP2)) return { type: 'Salt Bridge', color: 'text-red-500', bg: 'bg-red-500/10', hex: '#ef4444' };
24+
25+
// 3. Cation-Pi
26+
const isA1 = AROMATIC.includes(r1);
27+
const isA2 = AROMATIC.includes(r2);
28+
if ((isP1 && isA2) || (isA1 && isP2)) return { type: 'Cation-Pi Interaction', color: 'text-indigo-500', bg: 'bg-indigo-500/10', hex: '#6366f1' };
29+
30+
// 4. Pi-Stacking
31+
if (isA1 && isA2) return { type: 'Pi-Stacking', color: 'text-purple-500', bg: 'bg-purple-500/10', hex: '#a855f7' };
32+
33+
// 5. Hydrophobic (Generic, check dist < 5.0 for meaningful core packing vs just 8.0 contact)
34+
const isH1 = HYDROPHOBIC.includes(r1);
35+
const isH2 = HYDROPHOBIC.includes(r2);
36+
if (isH1 && isH2 && dist < 5.0) return { type: 'Hydrophobic Contact', color: 'text-green-500', bg: 'bg-green-500/10', hex: '#22c55e' };
37+
38+
// Fallback for close contacts
39+
if (dist < 4.0) return { type: 'Close Contact', color: 'text-neutral-500', bg: 'bg-neutral-500/10', hex: '#737373' };
40+
41+
return null;
42+
};

src/utils/pdfGenerator.ts

Lines changed: 145 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,141 +1,190 @@
11
import jsPDF from 'jspdf';
22
import autoTable from 'jspdf-autotable';
3+
import { getInteractionType } from './interactionUtils';
34

45
interface InteractionData {
56
matrix: number[][];
67
size: number;
78
labels: { resNo: number; chain: string; label: string; ss: string }[];
89
}
910

10-
export const generateProteinReport = (
11-
proteinName: string,
12-
contactMapCanvas: HTMLCanvasElement,
13-
data: InteractionData
14-
) => {
15-
// 1. Initialize PDF
16-
const doc = new jsPDF();
17-
const width = doc.internal.pageSize.getWidth();
18-
const height = doc.internal.pageSize.getHeight();
19-
const margin = 15;
11+
// Helper: Draw map to an offscreen canvas
12+
const drawMapToDataURL = (
13+
data: InteractionData,
14+
filterFn: (type: string | null) => boolean,
15+
isLightMode: boolean
16+
): string => {
17+
const canvas = document.createElement('canvas');
18+
const P = 2; // Pixel scale for high res
19+
const size = data.size;
20+
canvas.width = size * P;
21+
canvas.height = size * P;
22+
const ctx = canvas.getContext('2d', { alpha: false });
2023

21-
// --- HEADER ---
22-
doc.setFont("helvetica", "bold");
23-
doc.setFontSize(22);
24-
doc.text("Protein Analysis Report", margin, 20);
24+
if (!ctx) return '';
2525

26-
doc.setFont("helvetica", "normal");
27-
doc.setFontSize(12);
28-
doc.text(`Protein: ${proteinName || "Unknown Structure"}`, margin, 30);
29-
doc.text(`Date: ${new Date().toLocaleDateString()}`, margin, 36);
30-
doc.text(`Residues: ${data.size}`, width - margin - 40, 30);
26+
// Bg
27+
ctx.fillStyle = isLightMode ? '#ffffff' : '#171717';
28+
ctx.fillRect(0, 0, canvas.width, canvas.height);
29+
30+
// Diagonals & Grid not strictly needed for print, but helpful context
31+
// Let's keep it simple: just the dots.
32+
33+
const { matrix, labels } = data;
3134

32-
// Line separator
33-
doc.setLineWidth(0.5);
34-
doc.line(margin, 42, width - margin, 42);
35+
for (let i = 0; i < size; i++) {
36+
for (let j = i; j < size; j++) {
37+
const dist = matrix[i][j];
38+
if (dist > 8.0) continue;
3539

40+
const l1 = labels[i];
41+
const l2 = labels[j];
3642

37-
// --- CONTACT MAP IMAGE ---
38-
// Capture the canvas as an image
39-
const mapImg = contactMapCanvas.toDataURL("image/png");
43+
// Filter
44+
const typeData = getInteractionType(l1.label, l2.label, dist);
45+
46+
// Check if we should draw this
47+
// Special case: "All" -> Draw everything
48+
// types: 'Salt Bridge', 'Disulfide Bond', 'Hydrophobic Contact', 'Pi-Stacking', 'Cation-Pi Interaction', 'Close Contact'
49+
50+
if (filterFn(typeData ? typeData.type : null)) {
51+
if (typeData) {
52+
ctx.fillStyle = typeData.hex || '#60a5fa'; // Fallback blue
53+
ctx.fillRect(j * P, i * P, P, P);
54+
// Mirror for symmetry
55+
ctx.fillRect(i * P, j * P, P, P);
56+
} else if (dist < 5.0 && filterFn('Close Contact')) {
57+
// Blue heatmap for generic close contacts if allowd
58+
ctx.fillStyle = isLightMode ? '#60a5fa' : '#3b82f6';
59+
ctx.fillRect(j * P, i * P, P, P);
60+
ctx.fillRect(i * P, j * P, P, P);
61+
}
62+
}
63+
}
64+
}
4065

41-
// Calculate aspect ratio to fit page width
42-
const imgProps = doc.getImageProperties(mapImg);
43-
const pdfImgWidth = width - (margin * 2);
44-
const pdfImgHeight = (imgProps.height * pdfImgWidth) / imgProps.width;
66+
return canvas.toDataURL("image/png");
67+
};
68+
69+
const addSection = (
70+
doc: jsPDF,
71+
title: string,
72+
data: InteractionData,
73+
filterFn: (type: string | null) => boolean,
74+
isLightMode: boolean,
75+
isFirstPage = false
76+
) => {
77+
const margin = 15;
78+
79+
80+
if (!isFirstPage) {
81+
doc.addPage();
82+
}
4583

46-
doc.setFontSize(14);
84+
// Title
4785
doc.setFont("helvetica", "bold");
48-
doc.text("Contact Map", margin, 55);
86+
doc.setFontSize(16);
87+
doc.text(title, margin, 20);
4988

50-
doc.addImage(mapImg, 'PNG', margin, 60, pdfImgWidth, pdfImgHeight);
89+
// 1. Generate Image
90+
const mapImg = drawMapToDataURL(data, filterFn, isLightMode);
5191

92+
// 2. Add Image
93+
const desiredImgWidth = 100; // Fixed size
94+
const desiredImgHeight = 100;
5295

53-
// --- INTERACTIONS TABLE ---
54-
// Extract "Key Interactions" (Salt Bridges, etc - purely distance based for now < 4A)
55-
// We want to avoid listing 10,000 hydrophobic contacts.
56-
// Let's filter for:
57-
// 1. Inter-chain contacts (Interface) < 5A
58-
// 2. Strong Salt Bridges (Arg/Lys <-> Asp/Glu) < 4A
96+
doc.addImage(mapImg, 'PNG', margin, 30, desiredImgWidth, desiredImgHeight);
5997

60-
const relevantInteractions = [] as any[];
98+
// 3. Generate Table Data
99+
const tableRows = [] as any[];
61100
const { matrix, labels } = data;
62101

63-
// Helper to detect residue type
64-
const getResType = (l: string) => l.trim().split(' ')[0].toUpperCase();
65-
const POSITIVE = ['ARG', 'LYS', 'HIS'];
66-
const NEGATIVE = ['ASP', 'GLU'];
102+
// We can't list ALL hydrophobic contacts (thousands). We need a limit.
103+
const MAX_ROWS = 100;
67104

68105
for (let i = 0; i < matrix.length; i++) {
69106
for (let j = i + 1; j < matrix.length; j++) {
70107
const dist = matrix[i][j];
71-
if (dist > 5.0) continue; // Skip far stuff
108+
if (dist > 6.0) continue;
72109

73110
const l1 = labels[i];
74111
const l2 = labels[j];
75-
76-
const r1 = getResType(l1.label);
77-
const r2 = getResType(l2.label);
78-
79-
let type = "Contact";
80-
let score = 0;
81-
82-
// Rule 1: Salt Bridge
83-
const isSaltBridge = (POSITIVE.includes(r1) && NEGATIVE.includes(r2)) || (POSITIVE.includes(r2) && NEGATIVE.includes(r1));
84-
85-
if (isSaltBridge && dist < 4.0) {
86-
type = "Salt Bridge";
87-
score = 10;
88-
} else if (l1.chain !== l2.chain) {
89-
type = "Interface";
90-
score = 5;
91-
} else if (dist < 3.5) {
92-
type = "Close Contact";
93-
score = 1;
94-
} else {
95-
continue; // Skip generic proximal contacts to save space
112+
const typeData = getInteractionType(l1.label, l2.label, dist);
113+
const type = typeData ? typeData.type : 'Close Contact';
114+
115+
// Filter Row
116+
if (filterFn(type)) {
117+
// If generic "All" view, skip 'Close Contact' logs to save space for important stuff
118+
if (title === "All Interactions" && type === "Close Contact") continue;
119+
120+
tableRows.push({
121+
res1: `${l1.chain}:${l1.label}`,
122+
res2: `${l2.chain}:${l2.label}`,
123+
dist: dist.toFixed(2),
124+
type: type
125+
});
96126
}
97-
98-
relevantInteractions.push({
99-
res1: `${l1.chain}:${l1.label}`,
100-
res2: `${l2.chain}:${l2.label}`,
101-
dist: dist.toFixed(2) + " Å",
102-
type: type,
103-
score: score
104-
});
105127
}
106128
}
107129

108-
// Sort by score (Salt bridges first) then distance
109-
relevantInteractions.sort((a, b) => {
110-
if (b.score !== a.score) return b.score - a.score;
111-
return parseFloat(a.dist) - parseFloat(b.dist);
112-
});
130+
// Sort by distance
131+
tableRows.sort((a, b) => parseFloat(a.dist) - parseFloat(b.dist));
113132

114-
// Top 50 only
115-
const topInteractions = relevantInteractions.slice(0, 50);
133+
// Slice
134+
const displayRows = tableRows.slice(0, MAX_ROWS);
116135

117-
// Add Table
118-
let tableStartY = 60 + pdfImgHeight + 20;
119-
120-
// Check if table fits on page, else new page
121-
if (tableStartY > height - 50) {
122-
doc.addPage();
123-
tableStartY = 20;
124-
}
125-
126-
doc.text("Top Significant Interactions", margin, tableStartY - 5);
136+
doc.setFontSize(10);
137+
doc.text(`Identified Interactions (${tableRows.length} total, top ${displayRows.length} shown)`, margin, 30 + desiredImgHeight + 10);
127138

128139
autoTable(doc, {
129-
startY: tableStartY,
130-
head: [['Residue A', 'Residue B', 'Distance', 'Type']],
131-
body: topInteractions.map(row => [row.res1, row.res2, row.dist, row.type]),
140+
startY: 30 + desiredImgHeight + 15,
141+
head: [['Residue A', 'Residue B', 'Dist (Å)', 'Type']],
142+
body: displayRows.map(r => [r.res1, r.res2, r.dist, r.type]),
132143
theme: 'striped',
133-
headStyles: { fillColor: [59, 130, 246] }, // Blue-500
134-
styles: { fontSize: 10 },
144+
styles: { fontSize: 8 },
135145
margin: { left: margin, right: margin }
136146
});
147+
};
148+
149+
export const generateProteinReport = (
150+
proteinName: string,
151+
_canvasIgnored: HTMLCanvasElement, // We draw our own now
152+
data: InteractionData
153+
) => {
154+
const doc = new jsPDF();
155+
const isLightMode = true; // Force light mode for print readability? Or pass user pref? Let's assume white paper = light mode.
156+
157+
// Page 1: Overview
158+
doc.setFont("helvetica", "bold");
159+
doc.setFontSize(22);
160+
doc.text("Protein Analysis Report", 15, 15);
161+
doc.setFontSize(12);
162+
doc.setFont("helvetica", "normal");
163+
doc.text(proteinName || "Unknown Protein", 15, 22);
164+
165+
// Section 1: All Interactions (Salt Bridges, Disulfides, Pi, Hydrophobic)
166+
// We allow everything except simple 'Close Contact' to declutter
167+
const allFilter = (t: string | null) => t !== null && t !== 'Close Contact';
168+
addSection(doc, "All Significant Interactions", data, allFilter, isLightMode, false);
169+
// Wait, first page logic is tricky with title.
170+
// Let's make "All Interactions" start below the main title.
171+
172+
// Let's redefine addSection usage slightly or just manually do Page 1.
173+
// Actually, let's just run addSection for everything and rely on auto-paging.
174+
175+
// 1. Salt Bridge
176+
addSection(doc, "Salt Bridges (Ionic Interactions)", data, (t) => t === 'Salt Bridge', isLightMode, true); // true = don't add page first (but we have title)
177+
178+
// 2. Disulfide
179+
addSection(doc, "Disulfide Bonds (Covalent)", data, (t) => t === 'Disulfide Bond', isLightMode, false);
180+
181+
// 3. Hydrophobic
182+
addSection(doc, "Hydrophobic Clusters", data, (t) => t === 'Hydrophobic Contact', isLightMode, false);
183+
184+
// 4. Pi-Stacking
185+
addSection(doc, "Pi-Stacking & Cation-Pi", data, (t) => t === 'Pi-Stacking' || t === 'Cation-Pi Interaction', isLightMode, false);
137186

138187
// Save
139188
const safeName = proteinName.replace(/[^a-z0-9]/yi, '_').toLowerCase();
140-
doc.save(`${safeName}_report.pdf`);
189+
doc.save(`${safeName}_full_report.pdf`);
141190
};

0 commit comments

Comments
 (0)