|
1 | 1 | import jsPDF from 'jspdf'; |
2 | 2 | import autoTable from 'jspdf-autotable'; |
| 3 | +import { getInteractionType } from './interactionUtils'; |
3 | 4 |
|
4 | 5 | interface InteractionData { |
5 | 6 | matrix: number[][]; |
6 | 7 | size: number; |
7 | 8 | labels: { resNo: number; chain: string; label: string; ss: string }[]; |
8 | 9 | } |
9 | 10 |
|
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 }); |
20 | 23 |
|
21 | | - // --- HEADER --- |
22 | | - doc.setFont("helvetica", "bold"); |
23 | | - doc.setFontSize(22); |
24 | | - doc.text("Protein Analysis Report", margin, 20); |
| 24 | + if (!ctx) return ''; |
25 | 25 |
|
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; |
31 | 34 |
|
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; |
35 | 39 |
|
| 40 | + const l1 = labels[i]; |
| 41 | + const l2 = labels[j]; |
36 | 42 |
|
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 | + } |
40 | 65 |
|
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 | + } |
45 | 83 |
|
46 | | - doc.setFontSize(14); |
| 84 | + // Title |
47 | 85 | doc.setFont("helvetica", "bold"); |
48 | | - doc.text("Contact Map", margin, 55); |
| 86 | + doc.setFontSize(16); |
| 87 | + doc.text(title, margin, 20); |
49 | 88 |
|
50 | | - doc.addImage(mapImg, 'PNG', margin, 60, pdfImgWidth, pdfImgHeight); |
| 89 | + // 1. Generate Image |
| 90 | + const mapImg = drawMapToDataURL(data, filterFn, isLightMode); |
51 | 91 |
|
| 92 | + // 2. Add Image |
| 93 | + const desiredImgWidth = 100; // Fixed size |
| 94 | + const desiredImgHeight = 100; |
52 | 95 |
|
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); |
59 | 97 |
|
60 | | - const relevantInteractions = [] as any[]; |
| 98 | + // 3. Generate Table Data |
| 99 | + const tableRows = [] as any[]; |
61 | 100 | const { matrix, labels } = data; |
62 | 101 |
|
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; |
67 | 104 |
|
68 | 105 | for (let i = 0; i < matrix.length; i++) { |
69 | 106 | for (let j = i + 1; j < matrix.length; j++) { |
70 | 107 | const dist = matrix[i][j]; |
71 | | - if (dist > 5.0) continue; // Skip far stuff |
| 108 | + if (dist > 6.0) continue; |
72 | 109 |
|
73 | 110 | const l1 = labels[i]; |
74 | 111 | 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 | + }); |
96 | 126 | } |
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 | | - }); |
105 | 127 | } |
106 | 128 | } |
107 | 129 |
|
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)); |
113 | 132 |
|
114 | | - // Top 50 only |
115 | | - const topInteractions = relevantInteractions.slice(0, 50); |
| 133 | + // Slice |
| 134 | + const displayRows = tableRows.slice(0, MAX_ROWS); |
116 | 135 |
|
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); |
127 | 138 |
|
128 | 139 | 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]), |
132 | 143 | theme: 'striped', |
133 | | - headStyles: { fillColor: [59, 130, 246] }, // Blue-500 |
134 | | - styles: { fontSize: 10 }, |
| 144 | + styles: { fontSize: 8 }, |
135 | 145 | margin: { left: margin, right: margin } |
136 | 146 | }); |
| 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); |
137 | 186 |
|
138 | 187 | // Save |
139 | 188 | const safeName = proteinName.replace(/[^a-z0-9]/yi, '_').toLowerCase(); |
140 | | - doc.save(`${safeName}_report.pdf`); |
| 189 | + doc.save(`${safeName}_full_report.pdf`); |
141 | 190 | }; |
0 commit comments