Skip to content

Commit efcdbbb

Browse files
AmirMohammad CheraghaliAmirMohammad Cheraghali
authored andcommitted
feat: implement sequence alignment view with Needleman-Wunsch algorithm
1 parent a9352bc commit efcdbbb

5 files changed

Lines changed: 290 additions & 5 deletions

File tree

src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2620,6 +2620,10 @@ function App() {
26202620
onAddOverlay={controllers[activeViewIndex].addOverlay}
26212621
onRemoveOverlay={controllers[activeViewIndex].removeOverlay}
26222622
onToggleOverlay={controllers[activeViewIndex].toggleOverlay}
2623+
onOpenAlignment={() => {
2624+
setIsSuperpositionModalOpen(false);
2625+
viewerRefs[activeViewIndex].current?.openAlignmentView();
2626+
}}
26232627
/>
26242628

26252629
<Settings

src/components/ProteinViewer.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
22
import clsx from 'clsx';
33
import { Skeleton } from './Skeleton';
4+
import { SequenceAlignmentModal } from './SequenceAlignmentModal';
45
import type { RepresentationType, ColoringType, ChainInfo, Measurement, CustomColorRule, CustomStyleRule, CustomTransparencyRule, ColorPalette, ResidueInfo, StructureInfo, MeasurementTextColor, AtomInfo, SuperposedStructure, Annotation } from '../types';
56
import { type DataSource, getStructureUrl } from '../utils/pdbUtils';
67

@@ -130,8 +131,10 @@ export interface ProteinViewerRef {
130131
setOrientation: (orientation: any) => void;
131132
getPdbBlob: () => Blob | null; // Method to extract current structure as blob
132133
container: HTMLDivElement | null; // Expose container for canvas access
134+
openAlignmentView: () => void; // Added for Sequence Alignment
133135
}
134136

137+
135138
export const ProteinViewer = forwardRef<ProteinViewerRef, ProteinViewerProps>(({
136139
pdbId,
137140
dataSource = 'pdb',
@@ -179,6 +182,8 @@ export const ProteinViewer = forwardRef<ProteinViewerRef, ProteinViewerProps>(({
179182
remoteHoveredResidue
180183
}: ProteinViewerProps, ref: React.Ref<ProteinViewerRef>) => {
181184

185+
const [isAlignmentOpen, setIsAlignmentOpen] = React.useState(false);
186+
const [primaryStructureChains, setPrimaryStructureChains] = React.useState<ChainInfo[] | undefined>(undefined);
182187
const containerRef = useRef<HTMLDivElement>(null);
183188
const stageRef = useRef<any>(null);
184189
const componentRef = useRef<any>(null);
@@ -529,6 +534,7 @@ export const ProteinViewer = forwardRef<ProteinViewerRef, ProteinViewerProps>(({
529534
};
530535

531536
useImperativeHandle(ref, () => ({
537+
openAlignmentView: () => setIsAlignmentOpen(true),
532538
recordMovie: performVideoRecord, // Exposed
533539
highlightRegion: (selection: string, _label?: string) => {
534540
if (!componentRef.current) return;
@@ -1880,6 +1886,8 @@ export const ProteinViewer = forwardRef<ProteinViewerRef, ProteinViewerProps>(({
18801886
if (onStructureLoaded) {
18811887
onStructureLoaded({ chains, ligands, isSmallMolecule });
18821888
}
1889+
// Store locally for Alignment View
1890+
setPrimaryStructureChains(chains);
18831891
} catch (e) { console.warn("Chain parsing error", e); }
18841892
}
18851893

@@ -2919,6 +2927,13 @@ export const ProteinViewer = forwardRef<ProteinViewerRef, ProteinViewerProps>(({
29192927
<div className={clsx("relative w-full h-full", className)} style={backgroundColor === 'transparent' ? { background: 'transparent' } : {}}>
29202928
<div ref={containerRef} className="w-full h-full" style={backgroundColor === 'transparent' ? { background: 'transparent' } : {}} />
29212929

2930+
<SequenceAlignmentModal
2931+
isOpen={isAlignmentOpen}
2932+
onClose={() => setIsAlignmentOpen(false)}
2933+
primaryStructure={primaryStructureChains}
2934+
overlays={overlays || []}
2935+
/>
2936+
29222937
{/* HTML Overlays for Annotations */}
29232938
{annotations && annotations.map(ann => {
29242939
const pos = overlayPositions[ann.id];
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
2+
import React, { useMemo, useState } from 'react';
3+
import { X, GitCommitVertical, AlertTriangle } from 'lucide-react';
4+
import type { ChainInfo, SuperposedStructure } from '../types';
5+
6+
interface SequenceAlignmentModalProps {
7+
isOpen: boolean;
8+
onClose: () => void;
9+
primaryStructure?: ChainInfo[];
10+
overlays: SuperposedStructure[];
11+
}
12+
13+
interface AlignedResult {
14+
overlayId: string;
15+
overlayName: string;
16+
chainMatches: {
17+
primaryChain: string; // e.g. "A"
18+
targetChain: string; // e.g. "A"
19+
score: number;
20+
alignment: {
21+
seq1: string; // Primary (with gaps)
22+
seq2: string; // Target (with gaps)
23+
identity: number; // Percent
24+
};
25+
}[];
26+
}
27+
28+
// Basic Needleman-Wunsch Implementation
29+
const alignSequences = (seq1: string, seq2: string) => {
30+
const match = 1;
31+
const mismatch = -1;
32+
const gap = -2;
33+
34+
const n = seq1.length;
35+
const m = seq2.length;
36+
37+
// Create matrix
38+
const score = Array(n + 1).fill(0).map(() => Array(m + 1).fill(0));
39+
40+
// Initialize
41+
for (let i = 0; i <= n; i++) score[i][0] = i * gap;
42+
for (let j = 0; j <= m; j++) score[0][j] = j * gap;
43+
44+
// Fill
45+
for (let i = 1; i <= n; i++) {
46+
for (let j = 1; j <= m; j++) {
47+
const isMatch = seq1[i - 1] === seq2[j - 1];
48+
score[i][j] = Math.max(
49+
score[i - 1][j - 1] + (isMatch ? match : mismatch),
50+
score[i - 1][j] + gap,
51+
score[i][j - 1] + gap
52+
);
53+
}
54+
}
55+
56+
// Traceback
57+
let align1 = "";
58+
let align2 = "";
59+
let i = n;
60+
let j = m;
61+
let matches = 0;
62+
63+
while (i > 0 || j > 0) {
64+
if (i > 0 && j > 0 && score[i][j] === score[i - 1][j - 1] + (seq1[i - 1] === seq2[j - 1] ? match : mismatch)) {
65+
align1 = seq1[i - 1] + align1;
66+
align2 = seq2[j - 1] + align2;
67+
if (seq1[i - 1] === seq2[j - 1]) matches++;
68+
i--;
69+
j--;
70+
} else if (i > 0 && score[i][j] === score[i - 1][j] + gap) {
71+
align1 = seq1[i - 1] + align1;
72+
align2 = "-" + align2;
73+
i--;
74+
} else {
75+
align1 = "-" + align1;
76+
align2 = seq2[j - 1] + align2;
77+
j--;
78+
}
79+
}
80+
81+
return {
82+
seq1: align1,
83+
seq2: align2,
84+
identity: matches / Math.max(align1.length, 1) * 100
85+
};
86+
};
87+
88+
export const SequenceAlignmentModal: React.FC<SequenceAlignmentModalProps> = ({
89+
isOpen,
90+
onClose,
91+
primaryStructure,
92+
overlays
93+
}) => {
94+
const [selectedChain, setSelectedChain] = useState<string | null>(null);
95+
96+
// Perform Alignments Memoized
97+
const alignmentResults = useMemo(() => {
98+
if (!primaryStructure || overlays.length === 0) return [];
99+
100+
const results: AlignedResult[] = [];
101+
102+
overlays.forEach(ov => {
103+
if (!ov.chains || ov.chains.length === 0) return;
104+
105+
const chainMatches: AlignedResult['chainMatches'] = [];
106+
107+
// Simple heuristic mapping: Match Chain A to Chain A
108+
primaryStructure.forEach(pChain => {
109+
const targetChain = ov.chains?.find(c => c.name === pChain.name) || ov.chains?.[0]; // Fallback to first if mismatch
110+
111+
if (targetChain) {
112+
const alignment = alignSequences(pChain.sequence, targetChain.sequence);
113+
chainMatches.push({
114+
primaryChain: pChain.name,
115+
targetChain: targetChain.name,
116+
score: alignment.identity,
117+
alignment
118+
});
119+
}
120+
});
121+
122+
results.push({
123+
overlayId: ov.id,
124+
overlayName: ov.description || ov.id,
125+
chainMatches
126+
});
127+
});
128+
129+
return results;
130+
}, [primaryStructure, overlays]);
131+
132+
133+
// Determine unique chains present in primary structure to filter tabs
134+
const availableChains = useMemo(() => {
135+
return primaryStructure?.map(c => c.name) || [];
136+
}, [primaryStructure]);
137+
138+
// Set default tab
139+
useMemo(() => {
140+
if (!selectedChain && availableChains.length > 0) {
141+
setSelectedChain(availableChains[0]);
142+
}
143+
}, [availableChains]);
144+
145+
146+
if (!isOpen) return null;
147+
148+
return (
149+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md p-4">
150+
<div className="bg-neutral-900 border border-neutral-700/50 rounded-xl shadow-2xl w-full max-w-5xl h-[80vh] flex flex-col overflow-hidden">
151+
{/* Header */}
152+
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-800 bg-neutral-900/50">
153+
<div className="flex items-center gap-3">
154+
<GitCommitVertical className="text-cyan-400" size={24} />
155+
<div>
156+
<h2 className="text-lg font-bold text-white">Sequence Alignment</h2>
157+
<p className="text-xs text-neutral-400">Pairwise alignment against primary structure (Needleman-Wunsch)</p>
158+
</div>
159+
</div>
160+
<button onClick={onClose} className="text-neutral-400 hover:text-white transition-colors">
161+
<X size={24} />
162+
</button>
163+
</div>
164+
165+
{/* Chain Selector Tabs */}
166+
<div className="flex gap-1 px-6 pt-4 border-b border-neutral-800 pb-0 overflow-x-auto scrollbar-hide">
167+
{availableChains.map(chain => (
168+
<button
169+
key={chain}
170+
onClick={() => setSelectedChain(chain)}
171+
className={`px-4 py-2 text-sm font-bold border-b-2 transition-colors ${selectedChain === chain
172+
? 'border-cyan-500 text-cyan-400 bg-cyan-500/5'
173+
: 'border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800'
174+
}`}
175+
>
176+
Chain {chain}
177+
</button>
178+
))}
179+
</div>
180+
181+
{/* Content */}
182+
<div className="flex-1 overflow-y-auto p-6 space-y-8 bg-[#0d1117]">
183+
{selectedChain && alignmentResults.map(result => {
184+
const match = result.chainMatches.find(m => m.primaryChain === selectedChain);
185+
if (!match) return null;
186+
187+
return (
188+
<div key={result.overlayId} className="space-y-2">
189+
<div className="flex items-center justify-between">
190+
<h3 className="text-sm font-bold text-neutral-200">
191+
vs. {result.overlayName} <span className="text-neutral-500 font-normal">(Chain {match.targetChain})</span>
192+
</h3>
193+
<span className={`text-xs px-2 py-0.5 rounded-full font-mono ${match.score > 80 ? 'bg-green-500/20 text-green-400' : match.score > 50 ? 'bg-yellow-500/20 text-yellow-400' : 'bg-red-500/20 text-red-400'}`}>
194+
{match.score.toFixed(1)}% Identity
195+
</span>
196+
</div>
197+
198+
<div className="font-mono text-[10px] sm:text-xs leading-relaxed bg-black/30 p-4 rounded-lg border border-neutral-800 overflow-x-auto">
199+
{/* Primary Seq */}
200+
<div className="whitespace-pre flex">
201+
<span className="w-20 inline-block text-neutral-500 shrink-0 select-none">Primary:</span>
202+
<div className="flex">
203+
{match.alignment.seq1.split('').map((char, i) => (
204+
<span key={i} className={`w-[8px] sm:w-[9px] text-center inline-block ${char === '-' ? 'text-neutral-700' : 'text-cyan-200'}`}>{char}</span>
205+
))}
206+
</div>
207+
</div>
208+
209+
{/* Match Line */}
210+
<div className="whitespace-pre flex my-0.5">
211+
<span className="w-20 inline-block shrink-0 select-none"></span>
212+
<div className="flex">
213+
{match.alignment.seq1.split('').map((c1, i) => {
214+
const c2 = match.alignment.seq2[i];
215+
const isMatch = c1 === c2 && c1 !== '-';
216+
return (
217+
<span key={i} className={`w-[8px] sm:w-[9px] text-center inline-block font-bold ${isMatch ? 'text-white' : 'text-transparent'}`}>
218+
{isMatch ? '|' : '.'}
219+
</span>
220+
);
221+
})}
222+
</div>
223+
</div>
224+
225+
{/* Overlay Seq */}
226+
<div className="whitespace-pre flex">
227+
<span className="w-20 inline-block text-neutral-500 shrink-0 select-none">Overlay:</span>
228+
<div className="flex">
229+
{match.alignment.seq2.split('').map((char, i) => {
230+
const c1 = match.alignment.seq1[i];
231+
const isMismatch = char !== '-' && c1 !== '-' && char !== c1;
232+
return (
233+
<span key={i} className={`w-[8px] sm:w-[9px] text-center inline-block ${char === '-' ? 'text-neutral-700' : isMismatch ? 'text-red-400 font-bold' : 'text-neutral-300'}`}>
234+
{char}
235+
</span>
236+
);
237+
})}
238+
</div>
239+
</div>
240+
</div>
241+
</div>
242+
);
243+
})}
244+
245+
{alignmentResults.length === 0 && (
246+
<div className="flex flex-col items-center justify-center p-12 text-neutral-500">
247+
<AlertTriangle size={48} className="mb-4 opacity-50" />
248+
<p>No alignment data available. Please add overlays first.</p>
249+
</div>
250+
)}
251+
</div>
252+
</div>
253+
</div>
254+
);
255+
};

src/components/SuperpositionModal.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ interface SuperpositionModalProps {
99
onAddOverlay: (structure: SuperposedStructure) => void;
1010
onRemoveOverlay: (id: string) => void;
1111
onToggleOverlay: (id: string) => void;
12+
onOpenAlignment: () => void; // New Prop
1213
}
1314

1415
export const SuperpositionModal: React.FC<SuperpositionModalProps> = ({
@@ -17,7 +18,8 @@ export const SuperpositionModal: React.FC<SuperpositionModalProps> = ({
1718
overlays,
1819
onAddOverlay,
1920
onRemoveOverlay,
20-
onToggleOverlay
21+
onToggleOverlay,
22+
onOpenAlignment
2123
}) => {
2224
const [pdbInput, setPdbInput] = useState('');
2325
const [colorInput, setColorInput] = useState('#FFA500'); // Default Orange
@@ -56,9 +58,18 @@ export const SuperpositionModal: React.FC<SuperpositionModalProps> = ({
5658
{/* Header */}
5759
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-800">
5860
<h2 className="text-lg font-semibold text-white">Structure Superposition</h2>
59-
<button onClick={onClose} className="text-neutral-400 hover:text-white transition-colors">
60-
<X size={20} />
61-
</button>
61+
<div className="flex gap-2">
62+
<button
63+
onClick={onOpenAlignment}
64+
title="View Sequence Alignment"
65+
className="bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 text-xs px-3 py-1.5 rounded-lg border border-cyan-500/30 transition-colors"
66+
>
67+
Align Sequences
68+
</button>
69+
<button onClick={onClose} className="text-neutral-400 hover:text-white transition-colors">
70+
<X size={20} />
71+
</button>
72+
</div>
6273
</div>
6374

6475
{/* Content */}

src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export interface SuperposedStructure {
8484
description?: string;
8585
isVisible: boolean;
8686
opacity?: number;
87-
87+
chains?: ChainInfo[]; // Added for Sequence Alignment
8888
}
8989

9090

0 commit comments

Comments
 (0)