Skip to content

Commit d1d80a4

Browse files
author
Selcuk
committed
Tree and MSA combined view added. Buttons fixed
1 parent 55d8484 commit d1d80a4

6 files changed

Lines changed: 372 additions & 136 deletions

File tree

src/app/receptor/page.tsx

Lines changed: 169 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useSearchParams } from 'next/navigation';
44
import { useEffect, useState, Suspense, lazy } from 'react';
55
import Link from 'next/link';
6-
import { ChevronLeft } from 'lucide-react';
6+
import { ChevronLeft, Download } from 'lucide-react';
77

88
import RootContainer from '@/components/RootContainer';
99
import DownloadableFiles from '@/components/DownloadableFiles';
@@ -13,10 +13,7 @@ import receptors from '../../../public/receptors.json';
1313
const ConservationChart = lazy(() => import('@/components/ConservationChart'));
1414
const SnakePlot = lazy(() => import('@/components/SnakePlot'));
1515
const 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 */
129205
const 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+
}

src/components/CombinedTreeAlignment.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export type CombinedTreeAlignmentProps = {
5151
containerBottomPadding?: number;
5252
// Alignment area padding
5353
alignmentRightPadding?: number;
54+
// Gap between column header (GPCRdb numbers) and top of sequences
55+
headerGapPx?: number;
5456
// Dark mode
5557
isDarkMode?: boolean;
5658
// Receptor data for GPCRdb numbering (will use conservationFile from receptor object)
@@ -360,8 +362,11 @@ export function CombinedTreeAlignment({
360362
// Compact header height for rotated GPCRdb numbers
361363
// alignmentHeaderHeight will be computed dynamically below based on widest label
362364

363-
// Fine-tune vertical gaps relative to the header bottom
364-
const headerToSeqGapPx = Math.max(2, Math.round(fontSize * 0.5)); // slightly larger gap for sequences
365+
// Fine-tune vertical gaps relative to the header bottom (configurable)
366+
const headerToSeqGapPx = useMemo(() => {
367+
// If headerGapPx provided, use it; otherwise default to ~0.5em of current font size
368+
return Math.max(0, typeof (arguments as unknown as { headerGapPx?: number }).headerGapPx === 'number' ? (arguments as unknown as { headerGapPx?: number }).headerGapPx! : Math.round(fontSize * 0.5));
369+
}, [fontSize]);
365370

366371
// Use fixed row spacing - no dynamic calculation based on container
367372
const dynamicRowSpacing = leafRowSpacing;
@@ -487,10 +492,10 @@ export function CombinedTreeAlignment({
487492
// Height of just the alignment body (rows area) excluding header and external paddings
488493
const alignmentBodyHeight = useMemo(() => {
489494
if (!laidOut) return 0;
490-
const leaves = laidOut.visibleLeaves;
491-
if (leaves.length === 0) return 0;
492-
const maxY = Math.max(...leaves.map(l => l.y || 0));
493-
return Math.max(0, maxY + dynamicRowSpacing / 2 + sequenceTopPadding + sequenceBottomPadding);
495+
const numRows = laidOut.visibleLeaves.length;
496+
if (numRows === 0) return 0;
497+
// Full rows height + vertical paddings to avoid clipping bottom row
498+
return numRows * dynamicRowSpacing + sequenceTopPadding + sequenceBottomPadding;
494499
}, [laidOut, dynamicRowSpacing, sequenceTopPadding, sequenceBottomPadding]);
495500

496501
// Consistent character width used for both headers and background stripes
@@ -538,7 +543,7 @@ export function CombinedTreeAlignment({
538543
const totalWidth = leftWidth + alignmentTotalWidth;
539544

540545
return (
541-
<div ref={containerRef} style={{ width: width ? `${width}px` : '100%', height: height ? `${height}px` : 'auto', overflow: 'auto', position: 'relative', background: backgroundColor }}>
546+
<div ref={containerRef} style={{ width: width ? `${width}px` : '100%', height: height ? `${height}px` : 'auto', overflow: 'auto', position: 'relative', background: backgroundColor }} data-plot="combined-tree-msa">
542547
{/* Content area with proper width to avoid empty space on right */}
543548
<div style={{ position: 'relative', width: totalWidth, height: contentHeight, background: backgroundColor }}>
544549
{/* Sticky header overlay: GPCRdb column numbers (placed before tree SVG to avoid being pushed down). */}

src/components/ConservationChart.tsx

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import React, { useEffect, useState, useRef, useCallback } from 'react';
44
import * as d3 from 'd3';
55
import type { NumberValue } from 'd3';
6+
import { Download } from 'lucide-react';
67

78
export interface ConservationDatum {
89
residue: number;
@@ -385,16 +386,85 @@ const ConservationChart: React.FC<ConservationChartProps> = ({ conservationFile,
385386
);
386387
}
387388

388-
/* ----------------------------------------------------------------------- */
389-
/* 6. Main rendered chart ------------------------------------------------- */
390-
/* ----------------------------------------------------------------------- */
391389
return (
392-
<div className="bg-card text-card-foreground rounded-lg p-6 shadow-md">
393-
<h2 className="mb-4 text-xl font-semibold text-foreground">Residue Conservation Bar Plot</h2>
394-
<div className="relative flex h-[300px] w-full overflow-hidden">
395-
<div ref={yAxisContainerRef} className="z-10 flex-shrink-0 bg-card" />
396-
<div className="flex-grow overflow-x-auto">
397-
<div ref={chartContainerRef} className="h-full" />
390+
<div className="bg-card text-card-foreground rounded-lg shadow-md" data-plot="conservation">
391+
<div className="p-6 border-b border-border flex items-center justify-between">
392+
<h2 className="text-xl font-semibold text-foreground">Residue Conservation Bar Plot</h2>
393+
<div className="flex items-center gap-2">
394+
{conservationFile && (
395+
<a
396+
href={`/${conservationFile}`}
397+
download
398+
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md border text-sm hover:bg-accent"
399+
data-action="download-conservation-file"
400+
>
401+
<Download className="h-4 w-4" />
402+
Conservation File
403+
</a>
404+
)}
405+
{conservationFile && (
406+
<button
407+
type="button"
408+
onClick={() => {
409+
const yAxisContainer = yAxisContainerRef.current;
410+
const chartContainer = chartContainerRef.current;
411+
if (!yAxisContainer || !chartContainer) return;
412+
413+
const yAxisSvg = yAxisContainer.querySelector('svg');
414+
const chartSvg = chartContainer.querySelector('svg');
415+
if (!yAxisSvg || !chartSvg) return;
416+
417+
const yAxisWidth = parseInt(yAxisSvg.getAttribute('width') || '80');
418+
const chartWidth = parseInt(chartSvg.getAttribute('width') || '800');
419+
const totalWidth = yAxisWidth + chartWidth;
420+
const totalHeight = parseInt(chartSvg.getAttribute('height') || '300');
421+
422+
const combinedSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
423+
combinedSvg.setAttribute('width', totalWidth.toString());
424+
combinedSvg.setAttribute('height', totalHeight.toString());
425+
combinedSvg.setAttribute('viewBox', `0 0 ${totalWidth} ${totalHeight}`);
426+
combinedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
427+
428+
const yAxisClone = yAxisSvg.cloneNode(true) as SVGElement;
429+
yAxisClone.setAttribute('x', '0');
430+
yAxisClone.setAttribute('y', '0');
431+
combinedSvg.appendChild(yAxisClone);
432+
433+
const chartClone = chartSvg.cloneNode(true) as SVGElement;
434+
chartClone.setAttribute('x', yAxisWidth.toString());
435+
chartClone.setAttribute('y', '0');
436+
combinedSvg.appendChild(chartClone);
437+
438+
const serializer = new XMLSerializer();
439+
const svgString = serializer.serializeToString(combinedSvg);
440+
const svgWithDeclaration = `<?xml version="1.0" encoding="UTF-8"?>\n${svgString}`;
441+
const blob = new Blob([svgWithDeclaration], { type: 'image/svg+xml' });
442+
const url = URL.createObjectURL(blob);
443+
const baseName = conservationFile ? conservationFile.split('/').pop()?.replace(/\.[^.]+$/, '') : 'conservation';
444+
const fileName = `${baseName || 'conservation'}_barplot.svg`;
445+
const link = document.createElement('a');
446+
link.href = url;
447+
link.download = fileName;
448+
document.body.appendChild(link);
449+
link.click();
450+
document.body.removeChild(link);
451+
URL.revokeObjectURL(url);
452+
}}
453+
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md border text-sm hover:bg-accent"
454+
data-action="download-conservation"
455+
>
456+
<Download className="h-4 w-4" />
457+
Download SVG
458+
</button>
459+
)}
460+
</div>
461+
</div>
462+
<div className="p-6">
463+
<div className="relative flex h-[300px] w-full overflow-hidden">
464+
<div ref={yAxisContainerRef} className="z-10 flex-shrink-0 bg-card" />
465+
<div className="flex-grow overflow-x-auto">
466+
<div ref={chartContainerRef} className="h-full" />
467+
</div>
398468
</div>
399469
</div>
400470

0 commit comments

Comments
 (0)