33import { useSearchParams } from 'next/navigation' ;
44import { useEffect , useState , Suspense , lazy } from 'react' ;
55import Link from 'next/link' ;
6- import { ChevronLeft } from 'lucide-react' ;
6+ import { ChevronLeft , Download } from 'lucide-react' ;
77
88import RootContainer from '@/components/RootContainer' ;
99import DownloadableFiles from '@/components/DownloadableFiles' ;
@@ -13,10 +13,7 @@ import receptors from '../../../public/receptors.json';
1313const ConservationChart = lazy ( ( ) => import ( '@/components/ConservationChart' ) ) ;
1414const SnakePlot = lazy ( ( ) => import ( '@/components/SnakePlot' ) ) ;
1515const SequenceLogoChart = lazy ( ( ) => import ( '@/components/SequenceLogoChart' ) ) ;
16- const SVGTree = lazy ( ( ) => import ( '@/components/SVGTree' ) ) ;
17- const MSAViewer = lazy ( ( ) =>
18- import ( '@/components/MSAViewer' ) . then ( m => ( { default : m . MSAViewer } ) ) ,
19- ) ;
16+ const CombinedTreeAlignment = lazy ( ( ) => import ( '@/components/CombinedTreeAlignment' ) ) ;
2017
2118/* ------------------------------------------------------------------------- */
2219/* ↓↓↓ 1. Tiny route entry — just a Suspense wrapper around the content ↓↓↓ */
@@ -103,6 +100,16 @@ function ReceptorContent() {
103100 < ChevronLeft className = "h-8 w-8" />
104101 < h1 className = "text-3xl font-bold" > { `${ receptor . geneName } - ${ receptor . name } ` } </ h1 >
105102 </ Link >
103+ < div className = "mt-4" >
104+ < button
105+ type = "button"
106+ className = "inline-flex items-center gap-2 px-3 py-1.5 rounded-md border text-sm hover:bg-accent"
107+ onClick = { ( ) => downloadAllSvgs ( receptor ) }
108+ data-action = "download-all-svgs"
109+ >
110+ < Download className = "h-4 w-4" /> Download All SVGs
111+ </ button >
112+ </ div >
106113 </ div >
107114
108115 { /* basic info card ----------------------------------------------- */ }
@@ -124,6 +131,75 @@ function ReceptorContent() {
124131 </ >
125132 ) ;
126133}
134+ function downloadAllSvgs ( receptor : Receptor ) {
135+ try {
136+ // Conservation chart (has its own exporter)
137+ const conservationButton = document . querySelector ( '[data-action="download-conservation"]' ) as HTMLButtonElement | null ;
138+ conservationButton ?. click ( ) ;
139+
140+ // Sequence logo: trigger its built-in button if present
141+ const logoButton = document . querySelector ( '[data-action="download-sequence-logo"]' ) as HTMLButtonElement | null ;
142+ logoButton ?. click ( ) ;
143+
144+ // Snake plot: trigger its built-in button if present
145+ const snakeButton = document . querySelector ( '[data-action="download-snakeplot"]' ) as HTMLButtonElement | null ;
146+ snakeButton ?. click ( ) ;
147+
148+ // Combined view: export outer container as SVG snapshot - rely on its own internal SVGs
149+ // We will try to find the right-side alignment SVG and left tree SVG and combine side-by-side similar to conservation export.
150+ const combinedContainer = document . querySelector ( '[data-plot="combined-tree-msa"]' ) as HTMLElement | null ;
151+ if ( combinedContainer ) {
152+ const svgs = combinedContainer . querySelectorAll ( 'svg' ) ;
153+ if ( svgs . length > 0 ) {
154+ // Compute total bounds by concatenating horizontally
155+ let totalWidth = 0 ;
156+ let maxHeight = 0 ;
157+ const clones : SVGElement [ ] = [ ] ;
158+ svgs . forEach ( ( svg ) => {
159+ const w = parseInt ( svg . getAttribute ( 'width' ) || '0' ) ;
160+ const h = parseInt ( svg . getAttribute ( 'height' ) || '0' ) ;
161+ totalWidth += w ;
162+ maxHeight = Math . max ( maxHeight , h ) ;
163+ clones . push ( svg . cloneNode ( true ) as SVGElement ) ;
164+ } ) ;
165+ if ( totalWidth > 0 && maxHeight > 0 ) {
166+ const combinedSvg = document . createElementNS ( 'http://www.w3.org/2000/svg' , 'svg' ) ;
167+ combinedSvg . setAttribute ( 'width' , `${ totalWidth } ` ) ;
168+ combinedSvg . setAttribute ( 'height' , `${ maxHeight } ` ) ;
169+ combinedSvg . setAttribute ( 'viewBox' , `0 0 ${ totalWidth } ${ maxHeight } ` ) ;
170+ combinedSvg . setAttribute ( 'xmlns' , 'http://www.w3.org/2000/svg' ) ;
171+
172+ let xOffset = 0 ;
173+ clones . forEach ( ( clone ) => {
174+ const w = parseInt ( clone . getAttribute ( 'width' ) || '0' ) ;
175+ const wrapper = document . createElementNS ( 'http://www.w3.org/2000/svg' , 'g' ) ;
176+ wrapper . setAttribute ( 'transform' , `translate(${ xOffset } ,0)` ) ;
177+ wrapper . appendChild ( clone ) ;
178+ combinedSvg . appendChild ( wrapper ) ;
179+ xOffset += w ;
180+ } ) ;
181+
182+ const serializer = new XMLSerializer ( ) ;
183+ const svgString = serializer . serializeToString ( combinedSvg ) ;
184+ const svgWithDeclaration = `<?xml version="1.0" encoding="UTF-8"?>\n${ svgString } ` ;
185+ const blob = new Blob ( [ svgWithDeclaration ] , { type : 'image/svg+xml' } ) ;
186+ const url = URL . createObjectURL ( blob ) ;
187+ const fileName = `${ receptor . geneName } _combined_tree_alignment.svg` ;
188+ const link = document . createElement ( 'a' ) ;
189+ link . href = url ;
190+ link . download = fileName ;
191+ document . body . appendChild ( link ) ;
192+ link . click ( ) ;
193+ document . body . removeChild ( link ) ;
194+ URL . revokeObjectURL ( url ) ;
195+ }
196+ }
197+ }
198+ } catch ( err ) {
199+ // best-effort: no-op on error
200+ console . error ( 'Download all SVGs error:' , err ) ;
201+ }
202+ }
127203
128204/* helper for tidy info pairs */
129205const InfoItem = ( { label, value } : { label : string ; value : string | number } ) => (
@@ -177,28 +253,12 @@ function SequentialSections({ receptor }: { receptor: Receptor }) {
177253 ) }
178254
179255 { sectionIndex >= 3 && (
180- < Suspense fallback = { < SectionSpinner title = "Phylogenetic Tree of Orthologs" /> } >
181- < SVGTree svgPath = { receptor . svgTree } onLoaded = { next ( 4 ) } />
182- </ Suspense >
183- ) }
184-
185- { sectionIndex >= 4 && (
186- < Suspense fallback = { < SectionSpinner title = "Multiple Sequence Alignment of Orthologs" /> } >
187- < MSAViewer
188- alignmentPath = { receptor . alignment }
189- conservationFile = { receptor . conservationFile }
190- onLoaded = { next ( 5 ) }
191- />
256+ < Suspense fallback = { < SectionSpinner title = "Tree and Multiple Sequence Alignment of Orthologs" /> } >
257+ < CombinedSection receptor = { receptor } onLoaded = { next ( 4 ) } />
192258 </ Suspense >
193259 ) }
194260
195- { sectionIndex >= 5 && (
196- < DownloadableFiles
197- tree = { receptor . tree }
198- alignment = { receptor . alignment }
199- conservationFile = { receptor . conservationFile }
200- />
201- ) }
261+ { /* Download buttons are moved into the combined section header */ }
202262 </ >
203263 ) ;
204264}
@@ -224,3 +284,88 @@ const SectionSpinner = ({ title }: { title: string }) => (
224284 </ div >
225285 </ div >
226286) ;
287+
288+ /* ------------------------------------------------------------------------- */
289+ /* ↓↓↓ 5. Combined Tree + Alignment Section ↓↓↓ */
290+ /* ------------------------------------------------------------------------- */
291+
292+ function CombinedSection ( { receptor, onLoaded } : { receptor : Receptor ; onLoaded : ( ) => void } ) {
293+ const [ loading , setLoading ] = useState < boolean > ( false ) ;
294+ const [ newick , setNewick ] = useState < string > ( '' ) ;
295+ const [ alignmentFasta , setAlignmentFasta ] = useState < string > ( '' ) ;
296+
297+ useEffect ( ( ) => {
298+ let cancelled = false ;
299+ setLoading ( true ) ;
300+ Promise . all ( [
301+ fetch ( receptor . tree ) . then ( res => res . text ( ) ) ,
302+ fetch ( receptor . alignment ) . then ( res => res . text ( ) ) ,
303+ ] )
304+ . then ( ( [ treeData , alignmentData ] ) => {
305+ if ( cancelled ) return ;
306+ setNewick ( treeData . trim ( ) ) ;
307+ setAlignmentFasta ( alignmentData ) ;
308+ } )
309+ . catch ( err => {
310+ console . error ( 'Error loading tree/alignment:' , err ) ;
311+ } )
312+ . finally ( ( ) => {
313+ if ( cancelled ) return ;
314+ setLoading ( false ) ;
315+ onLoaded ( ) ;
316+ } ) ;
317+ return ( ) => {
318+ cancelled = true ;
319+ } ;
320+ } , [ receptor . tree , receptor . alignment , onLoaded ] ) ;
321+
322+ return (
323+ < div className = "bg-card text-card-foreground rounded-lg shadow-md" >
324+ < div className = "p-6 border-b border-border flex items-center justify-between" >
325+ < h2 className = "text-xl font-semibold text-foreground" > Tree and Multiple Sequence Alignment of Orthologs</ h2 >
326+ < div className = "flex items-center gap-2" >
327+ { receptor . tree && (
328+ < a href = { `/${ receptor . tree } ` } download className = "inline-flex items-center gap-2 px-3 py-1.5 rounded-md border text-sm hover:bg-accent" >
329+ < Download className = "h-4 w-4" /> Tree
330+ </ a >
331+ ) }
332+ { receptor . alignment && (
333+ < a href = { `/${ receptor . alignment } ` } download className = "inline-flex items-center gap-2 px-3 py-1.5 rounded-md border text-sm hover:bg-accent" >
334+ < Download className = "h-4 w-4" /> Alignment
335+ </ a >
336+ ) }
337+ { /* Conservation download moved into the Conservation Bar Plot header */ }
338+ </ div >
339+ </ div >
340+ < div className = "p-6" >
341+ < div className = "rounded-lg bg-card text-card-foreground" style = { { height : 600 } } >
342+ { loading ? (
343+ < div className = "flex items-center justify-center h-full" >
344+ < div className = "text-center" >
345+ < div className = "h-8 w-8 animate-spin rounded-full border-b-2 border-foreground mx-auto mb-2" />
346+ < p className = "text-muted-foreground" > Loading tree and alignment data...</ p >
347+ </ div >
348+ </ div >
349+ ) : newick ? (
350+ < CombinedTreeAlignment
351+ newick = { newick }
352+ alignmentFasta = { alignmentFasta }
353+ receptor = { receptor }
354+ showSupportOnBranches = { false }
355+ mirrorRightToLeft = { false }
356+ fontSize = { 14 }
357+ leafRowSpacing = { 13 }
358+ treeWidthPx = { 300 }
359+ alignmentBoxWidthPx = { 900 }
360+ height = { 600 }
361+ />
362+ ) : (
363+ < div className = "flex items-center justify-center h-full" >
364+ < p className = "text-muted-foreground" > Unable to load tree/alignment for this receptor.</ p >
365+ </ div >
366+ ) }
367+ </ div >
368+ </ div >
369+ </ div >
370+ ) ;
371+ }
0 commit comments