Spaces:
Sleeping
Sleeping
RaedAddala
RaedAddala
commited on
Commit
·
af68ace
1
Parent(s):
3a0ae3e
feat: add complete visualization system from feat/raed-ui
Browse files- Add dashboard layout with sidebar navigation
- Add molecules 2D page with SMILES visualization (smiles-drawer)
- Add molecules 3D page with 3Dmol.js viewer
- Add proteins 3D page with PDB structure viewer
- Add API routes for molecules and proteins (mock data)
- Add scroll-area and alert UI components
- Add theme toggle component
- Update sidebar with visualization menu items
- Update routes to use /dashboard prefix
Co-authored-by: RaedAddala <raed.addala@example.com>
- ui/app/api/_mock/molecules.ts +31 -0
- ui/app/api/_mock/proteins.ts +27 -0
- ui/app/api/molecules/[id]/route.ts +17 -0
- ui/app/api/molecules/[id]/sdf/route.ts +60 -0
- ui/app/api/molecules/route.ts +7 -0
- ui/app/api/proteins/[id]/pdb/route.ts +45 -0
- ui/app/api/proteins/[id]/route.ts +17 -0
- ui/app/api/proteins/route.ts +7 -0
- ui/app/dashboard/layout.tsx +19 -0
- ui/app/dashboard/molecules-2d/_components/Smiles2DViewer.tsx +119 -0
- ui/app/dashboard/molecules-2d/page.tsx +223 -0
- ui/app/dashboard/molecules-3d/_components/Molecule3DViewer.tsx +191 -0
- ui/app/dashboard/molecules-3d/page.tsx +264 -0
- ui/app/dashboard/page.tsx +172 -0
- ui/app/dashboard/proteins-3d/_components/ProteinViewer.tsx +243 -0
- ui/app/dashboard/proteins-3d/page.tsx +227 -0
- ui/app/visualization/page.tsx +190 -0
- ui/components/sidebar.tsx +32 -23
- ui/components/theme-toggle.tsx +43 -0
- ui/components/ui/alert.tsx +66 -0
- ui/components/ui/scroll-area.tsx +58 -0
- ui/lib/visualization-api.ts +3 -2
- ui/package.json +2 -0
- ui/pnpm-lock.yaml +17 -0
- ui/types/smiles-drawer.d.ts +33 -0
ui/app/api/_mock/molecules.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type Molecule = {
|
| 2 |
+
id: string;
|
| 3 |
+
name: string;
|
| 4 |
+
smiles: string;
|
| 5 |
+
pubchemCid: number;
|
| 6 |
+
description?: string;
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export const molecules: Molecule[] = [
|
| 10 |
+
{
|
| 11 |
+
id: 'caffeine',
|
| 12 |
+
name: 'Caffeine',
|
| 13 |
+
smiles: 'CN1C=NC2=C1C(=O)N(C(=O)N2C)C',
|
| 14 |
+
pubchemCid: 2519,
|
| 15 |
+
description: 'Xanthine alkaloid found in coffee and tea',
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
id: 'aspirin',
|
| 19 |
+
name: 'Aspirin',
|
| 20 |
+
smiles: 'CC(=O)OC1=CC=CC=C1C(=O)O',
|
| 21 |
+
pubchemCid: 2244,
|
| 22 |
+
description: 'Acetylsalicylic acid - common pain reliever',
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
id: 'ibuprofen',
|
| 26 |
+
name: 'Ibuprofen',
|
| 27 |
+
smiles: 'CC(C)CC1=CC=C(C=C1)C(C)C(=O)O',
|
| 28 |
+
pubchemCid: 3672,
|
| 29 |
+
description: 'Non-steroidal anti-inflammatory drug (NSAID)',
|
| 30 |
+
},
|
| 31 |
+
];
|
ui/app/api/_mock/proteins.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type Protein = {
|
| 2 |
+
id: string;
|
| 3 |
+
pdbId: string;
|
| 4 |
+
name: string;
|
| 5 |
+
description?: string;
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
export const proteins: Protein[] = [
|
| 9 |
+
{
|
| 10 |
+
id: '1CRN',
|
| 11 |
+
pdbId: '1CRN',
|
| 12 |
+
name: 'Crambin',
|
| 13 |
+
description: 'Small hydrophobic protein from Crambe abyssinica seeds',
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
id: '1UBQ',
|
| 17 |
+
pdbId: '1UBQ',
|
| 18 |
+
name: 'Ubiquitin',
|
| 19 |
+
description: 'Regulatory protein found in most eukaryotic cells',
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
id: '4HHB',
|
| 23 |
+
pdbId: '4HHB',
|
| 24 |
+
name: 'Hemoglobin',
|
| 25 |
+
description: 'Iron-containing oxygen-transport protein in red blood cells',
|
| 26 |
+
},
|
| 27 |
+
];
|
ui/app/api/molecules/[id]/route.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
import { molecules } from '../../_mock/molecules';
|
| 4 |
+
|
| 5 |
+
export async function GET(
|
| 6 |
+
_request: Request,
|
| 7 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 8 |
+
) {
|
| 9 |
+
const { id } = await params;
|
| 10 |
+
const molecule = molecules.find((m) => m.id === id);
|
| 11 |
+
|
| 12 |
+
if (!molecule) {
|
| 13 |
+
return NextResponse.json({ error: 'Molecule not found' }, { status: 404 });
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
return NextResponse.json(molecule);
|
| 17 |
+
}
|
ui/app/api/molecules/[id]/sdf/route.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
import { molecules } from '../../../_mock/molecules';
|
| 4 |
+
|
| 5 |
+
export async function GET(
|
| 6 |
+
_request: Request,
|
| 7 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 8 |
+
) {
|
| 9 |
+
const { id } = await params;
|
| 10 |
+
const molecule = molecules.find((m) => m.id === id);
|
| 11 |
+
|
| 12 |
+
if (!molecule) {
|
| 13 |
+
return NextResponse.json({ error: 'Molecule not found' }, { status: 404 });
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
try {
|
| 17 |
+
const pubchemUrl = `https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/${molecule.pubchemCid}/record/SDF?record_type=3d`;
|
| 18 |
+
const response = await fetch(pubchemUrl, {
|
| 19 |
+
next: { revalidate: 3600 }, // Cache for 1 hour
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
if (!response.ok) {
|
| 23 |
+
// Try 2D SDF as fallback if 3D is not available
|
| 24 |
+
const pubchemUrl2D = `https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/${molecule.pubchemCid}/record/SDF`;
|
| 25 |
+
const response2D = await fetch(pubchemUrl2D, {
|
| 26 |
+
next: { revalidate: 3600 },
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
if (!response2D.ok) {
|
| 30 |
+
return NextResponse.json(
|
| 31 |
+
{ error: 'Failed to fetch SDF from PubChem' },
|
| 32 |
+
{ status: 502 }
|
| 33 |
+
);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const sdfText = await response2D.text();
|
| 37 |
+
return new NextResponse(sdfText, {
|
| 38 |
+
headers: {
|
| 39 |
+
'Content-Type': 'chemical/x-mdl-sdfile',
|
| 40 |
+
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
|
| 41 |
+
},
|
| 42 |
+
});
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const sdfText = await response.text();
|
| 46 |
+
|
| 47 |
+
return new NextResponse(sdfText, {
|
| 48 |
+
headers: {
|
| 49 |
+
'Content-Type': 'chemical/x-mdl-sdfile',
|
| 50 |
+
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
|
| 51 |
+
},
|
| 52 |
+
});
|
| 53 |
+
} catch (error) {
|
| 54 |
+
console.error('Error fetching SDF:', error);
|
| 55 |
+
return NextResponse.json(
|
| 56 |
+
{ error: 'Failed to fetch SDF data' },
|
| 57 |
+
{ status: 500 }
|
| 58 |
+
);
|
| 59 |
+
}
|
| 60 |
+
}
|
ui/app/api/molecules/route.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
import { molecules } from '../_mock/molecules';
|
| 4 |
+
|
| 5 |
+
export async function GET() {
|
| 6 |
+
return NextResponse.json(molecules);
|
| 7 |
+
}
|
ui/app/api/proteins/[id]/pdb/route.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
import { proteins } from '../../../_mock/proteins';
|
| 4 |
+
|
| 5 |
+
export async function GET(
|
| 6 |
+
_request: Request,
|
| 7 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 8 |
+
) {
|
| 9 |
+
const { id } = await params;
|
| 10 |
+
const protein = proteins.find((p) => p.id === id || p.pdbId === id);
|
| 11 |
+
|
| 12 |
+
if (!protein) {
|
| 13 |
+
return NextResponse.json({ error: 'Protein not found' }, { status: 404 });
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
try {
|
| 17 |
+
const rcsbUrl = `https://files.rcsb.org/download/${protein.pdbId}.pdb`;
|
| 18 |
+
|
| 19 |
+
const response = await fetch(rcsbUrl, {
|
| 20 |
+
next: { revalidate: 3600 }, // Cache for 1 hour
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
if (!response.ok) {
|
| 24 |
+
return NextResponse.json(
|
| 25 |
+
{ error: `Failed to fetch PDB from RCSB: ${response.statusText}` },
|
| 26 |
+
{ status: 502 }
|
| 27 |
+
);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const pdbText = await response.text();
|
| 31 |
+
|
| 32 |
+
return new NextResponse(pdbText, {
|
| 33 |
+
headers: {
|
| 34 |
+
'Content-Type': 'text/plain',
|
| 35 |
+
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
|
| 36 |
+
},
|
| 37 |
+
});
|
| 38 |
+
} catch (error) {
|
| 39 |
+
console.error('Error fetching PDB:', error);
|
| 40 |
+
return NextResponse.json(
|
| 41 |
+
{ error: 'Failed to fetch PDB data' },
|
| 42 |
+
{ status: 500 }
|
| 43 |
+
);
|
| 44 |
+
}
|
| 45 |
+
}
|
ui/app/api/proteins/[id]/route.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
import { proteins } from '../../_mock/proteins';
|
| 4 |
+
|
| 5 |
+
export async function GET(
|
| 6 |
+
_request: Request,
|
| 7 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 8 |
+
) {
|
| 9 |
+
const { id } = await params;
|
| 10 |
+
const protein = proteins.find((p) => p.id === id || p.pdbId === id);
|
| 11 |
+
|
| 12 |
+
if (!protein) {
|
| 13 |
+
return NextResponse.json({ error: 'Protein not found' }, { status: 404 });
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
return NextResponse.json(protein);
|
| 17 |
+
}
|
ui/app/api/proteins/route.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
import { proteins } from '../_mock/proteins';
|
| 4 |
+
|
| 5 |
+
export async function GET() {
|
| 6 |
+
return NextResponse.json(proteins);
|
| 7 |
+
}
|
ui/app/dashboard/layout.tsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ReactNode } from 'react';
|
| 2 |
+
import {
|
| 3 |
+
SidebarInset,
|
| 4 |
+
SidebarProvider,
|
| 5 |
+
} from "@/components/animate-ui/components/radix/sidebar";
|
| 6 |
+
import { AppSidebar } from "@/components/sidebar";
|
| 7 |
+
|
| 8 |
+
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
| 9 |
+
return (
|
| 10 |
+
<SidebarProvider>
|
| 11 |
+
<AppSidebar />
|
| 12 |
+
<SidebarInset>
|
| 13 |
+
<main className="flex-1 overflow-y-auto bg-background">
|
| 14 |
+
{children}
|
| 15 |
+
</main>
|
| 16 |
+
</SidebarInset>
|
| 17 |
+
</SidebarProvider>
|
| 18 |
+
);
|
| 19 |
+
}
|
ui/app/dashboard/molecules-2d/_components/Smiles2DViewer.tsx
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { AlertCircle } from 'lucide-react';
|
| 4 |
+
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
| 5 |
+
import SmilesDrawer from 'smiles-drawer';
|
| 6 |
+
|
| 7 |
+
import { Card, CardContent } from '@/components/ui/card';
|
| 8 |
+
import { Skeleton } from '@/components/ui/skeleton';
|
| 9 |
+
|
| 10 |
+
interface Smiles2DViewerProps {
|
| 11 |
+
smiles: string;
|
| 12 |
+
width?: number;
|
| 13 |
+
height?: number;
|
| 14 |
+
className?: string;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function Smiles2DViewer({
|
| 18 |
+
smiles,
|
| 19 |
+
width = 400,
|
| 20 |
+
height = 300,
|
| 21 |
+
className,
|
| 22 |
+
}: Smiles2DViewerProps) {
|
| 23 |
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 24 |
+
const canvasId = useId();
|
| 25 |
+
const [error, setError] = useState<string | null>(null);
|
| 26 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 27 |
+
|
| 28 |
+
const drawMolecule = useCallback(async () => {
|
| 29 |
+
if (!canvasRef.current || !smiles) {
|
| 30 |
+
setIsLoading(false);
|
| 31 |
+
return;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
setIsLoading(true);
|
| 35 |
+
setError(null);
|
| 36 |
+
|
| 37 |
+
try {
|
| 38 |
+
const drawer = new SmilesDrawer.SmiDrawer({
|
| 39 |
+
width,
|
| 40 |
+
height,
|
| 41 |
+
bondThickness: 1.5,
|
| 42 |
+
bondLength: 15,
|
| 43 |
+
shortBondLength: 0.85,
|
| 44 |
+
bondSpacing: 4,
|
| 45 |
+
atomVisualization: 'default',
|
| 46 |
+
isomeric: true,
|
| 47 |
+
debug: false,
|
| 48 |
+
terminalCarbons: false,
|
| 49 |
+
explicitHydrogens: false,
|
| 50 |
+
compactDrawing: true,
|
| 51 |
+
fontSizeLarge: 11,
|
| 52 |
+
fontSizeSmall: 8,
|
| 53 |
+
padding: 20,
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
try {
|
| 57 |
+
await new Promise<void>((resolve, reject) => {
|
| 58 |
+
(drawer as any).draw(
|
| 59 |
+
smiles,
|
| 60 |
+
`[id="${canvasId}"]`,
|
| 61 |
+
'light',
|
| 62 |
+
() => resolve(),
|
| 63 |
+
(drawError: Error) => reject(drawError)
|
| 64 |
+
);
|
| 65 |
+
});
|
| 66 |
+
} catch (drawError) {
|
| 67 |
+
throw drawError;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
setIsLoading(false);
|
| 71 |
+
} catch (err) {
|
| 72 |
+
console.error('SMILES drawing error:', err);
|
| 73 |
+
setError(
|
| 74 |
+
err instanceof Error
|
| 75 |
+
? `Invalid SMILES: ${err.message}`
|
| 76 |
+
: 'Failed to render molecule structure'
|
| 77 |
+
);
|
| 78 |
+
setIsLoading(false);
|
| 79 |
+
}
|
| 80 |
+
}, [smiles, width, height, canvasId]);
|
| 81 |
+
|
| 82 |
+
useEffect(() => {
|
| 83 |
+
drawMolecule();
|
| 84 |
+
}, [drawMolecule]);
|
| 85 |
+
|
| 86 |
+
if (error) {
|
| 87 |
+
return (
|
| 88 |
+
<Card className={`border-destructive bg-destructive/10 ${className}`}>
|
| 89 |
+
<CardContent className="flex items-center gap-3 p-4">
|
| 90 |
+
<AlertCircle className="size-5 text-destructive" />
|
| 91 |
+
<div className="flex flex-col">
|
| 92 |
+
<span className="text-sm font-medium text-destructive">
|
| 93 |
+
Visualization Error
|
| 94 |
+
</span>
|
| 95 |
+
<span className="text-xs text-muted-foreground">{error}</span>
|
| 96 |
+
</div>
|
| 97 |
+
</CardContent>
|
| 98 |
+
</Card>
|
| 99 |
+
);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
return (
|
| 103 |
+
<div className={`relative ${className}`}>
|
| 104 |
+
{isLoading && (
|
| 105 |
+
<Skeleton
|
| 106 |
+
className="absolute inset-0"
|
| 107 |
+
style={{ width, height }}
|
| 108 |
+
/>
|
| 109 |
+
)}
|
| 110 |
+
<canvas
|
| 111 |
+
ref={canvasRef}
|
| 112 |
+
id={canvasId}
|
| 113 |
+
width={width}
|
| 114 |
+
height={height}
|
| 115 |
+
className={`bg-background rounded-lg ${isLoading ? 'opacity-0' : 'opacity-100'} transition-opacity duration-200`}
|
| 116 |
+
/>
|
| 117 |
+
</div>
|
| 118 |
+
);
|
| 119 |
+
}
|
ui/app/dashboard/molecules-2d/page.tsx
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { Check, Copy, Search } from 'lucide-react';
|
| 4 |
+
import dynamic from 'next/dynamic';
|
| 5 |
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
| 6 |
+
|
| 7 |
+
import { Badge } from '@/components/ui/badge';
|
| 8 |
+
import { Button } from '@/components/ui/button';
|
| 9 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
| 10 |
+
import { Input } from '@/components/ui/input';
|
| 11 |
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
| 12 |
+
import { Separator } from '@/components/ui/separator';
|
| 13 |
+
import { Skeleton } from '@/components/ui/skeleton';
|
| 14 |
+
import { getMolecules } from '@/lib/visualization-api';
|
| 15 |
+
import type { Molecule } from '@/lib/visualization-types';
|
| 16 |
+
|
| 17 |
+
// Dynamic import of the 2D viewer to prevent SSR issues
|
| 18 |
+
const Smiles2DViewer = dynamic(
|
| 19 |
+
() =>
|
| 20 |
+
import('./_components/Smiles2DViewer').then((mod) => mod.Smiles2DViewer),
|
| 21 |
+
{
|
| 22 |
+
ssr: false,
|
| 23 |
+
loading: () => <Skeleton className="size-[400px]" />,
|
| 24 |
+
}
|
| 25 |
+
);
|
| 26 |
+
|
| 27 |
+
export default function Molecules2DPage() {
|
| 28 |
+
const [molecules, setMolecules] = useState<Molecule[]>([]);
|
| 29 |
+
const [selectedMolecule, setSelectedMolecule] = useState<Molecule | null>(null);
|
| 30 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 31 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 32 |
+
const [error, setError] = useState<string | null>(null);
|
| 33 |
+
const [copied, setCopied] = useState(false);
|
| 34 |
+
|
| 35 |
+
useEffect(() => {
|
| 36 |
+
const loadMolecules = async () => {
|
| 37 |
+
try {
|
| 38 |
+
setIsLoading(true);
|
| 39 |
+
const data = await getMolecules();
|
| 40 |
+
setMolecules(data);
|
| 41 |
+
if (data.length > 0) {
|
| 42 |
+
setSelectedMolecule(data[0]);
|
| 43 |
+
}
|
| 44 |
+
} catch (err) {
|
| 45 |
+
setError(
|
| 46 |
+
err instanceof Error ? err.message : 'Failed to load molecules'
|
| 47 |
+
);
|
| 48 |
+
} finally {
|
| 49 |
+
setIsLoading(false);
|
| 50 |
+
}
|
| 51 |
+
};
|
| 52 |
+
loadMolecules();
|
| 53 |
+
}, []);
|
| 54 |
+
|
| 55 |
+
const filteredMolecules = useMemo(() => {
|
| 56 |
+
if (!searchQuery.trim()) return molecules;
|
| 57 |
+
const query = searchQuery.toLowerCase();
|
| 58 |
+
return molecules.filter(
|
| 59 |
+
(m) =>
|
| 60 |
+
m.name.toLowerCase().includes(query) ||
|
| 61 |
+
m.smiles.toLowerCase().includes(query)
|
| 62 |
+
);
|
| 63 |
+
}, [molecules, searchQuery]);
|
| 64 |
+
|
| 65 |
+
const handleCopySmiles = useCallback(async () => {
|
| 66 |
+
if (!selectedMolecule) return;
|
| 67 |
+
try {
|
| 68 |
+
await navigator.clipboard.writeText(selectedMolecule.smiles);
|
| 69 |
+
setCopied(true);
|
| 70 |
+
setTimeout(() => setCopied(false), 2000);
|
| 71 |
+
} catch {
|
| 72 |
+
// Fallback for older browsers
|
| 73 |
+
const textArea = document.createElement('textarea');
|
| 74 |
+
textArea.value = selectedMolecule.smiles;
|
| 75 |
+
document.body.appendChild(textArea);
|
| 76 |
+
textArea.select();
|
| 77 |
+
document.execCommand('copy');
|
| 78 |
+
document.body.removeChild(textArea);
|
| 79 |
+
setCopied(true);
|
| 80 |
+
setTimeout(() => setCopied(false), 2000);
|
| 81 |
+
}
|
| 82 |
+
}, [selectedMolecule]);
|
| 83 |
+
|
| 84 |
+
if (error) {
|
| 85 |
+
return (
|
| 86 |
+
<div className="flex h-full items-center justify-center">
|
| 87 |
+
<Card className="max-w-md">
|
| 88 |
+
<CardHeader>
|
| 89 |
+
<CardTitle className="text-destructive">Error</CardTitle>
|
| 90 |
+
<CardDescription>{error}</CardDescription>
|
| 91 |
+
</CardHeader>
|
| 92 |
+
</Card>
|
| 93 |
+
</div>
|
| 94 |
+
);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
return (
|
| 98 |
+
<div className="flex h-full gap-6 p-6">
|
| 99 |
+
{/* Left Panel - Molecule List */}
|
| 100 |
+
<Card className="w-80 shrink-0">
|
| 101 |
+
<CardHeader className="pb-3">
|
| 102 |
+
<CardTitle className="text-lg">Molecules</CardTitle>
|
| 103 |
+
<CardDescription>Select a molecule to visualize</CardDescription>
|
| 104 |
+
</CardHeader>
|
| 105 |
+
<CardContent className="pb-3">
|
| 106 |
+
<div className="relative">
|
| 107 |
+
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
| 108 |
+
<Input
|
| 109 |
+
placeholder="Search molecules..."
|
| 110 |
+
className="pl-8"
|
| 111 |
+
value={searchQuery}
|
| 112 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 113 |
+
/>
|
| 114 |
+
</div>
|
| 115 |
+
</CardContent>
|
| 116 |
+
<Separator />
|
| 117 |
+
<ScrollArea className="h-[calc(100vh-280px)]">
|
| 118 |
+
<div className="p-2">
|
| 119 |
+
{isLoading ? (
|
| 120 |
+
<div className="space-y-2">
|
| 121 |
+
{[1, 2, 3].map((i) => (
|
| 122 |
+
<Skeleton key={i} className="h-16 w-full" />
|
| 123 |
+
))}
|
| 124 |
+
</div>
|
| 125 |
+
) : filteredMolecules.length === 0 ? (
|
| 126 |
+
<p className="p-4 text-center text-sm text-muted-foreground">
|
| 127 |
+
No molecules found
|
| 128 |
+
</p>
|
| 129 |
+
) : (
|
| 130 |
+
<div className="space-y-1">
|
| 131 |
+
{filteredMolecules.map((molecule) => (
|
| 132 |
+
<button
|
| 133 |
+
key={molecule.id}
|
| 134 |
+
onClick={() => setSelectedMolecule(molecule)}
|
| 135 |
+
className={`w-full rounded-lg p-3 text-left transition-colors hover:bg-accent ${
|
| 136 |
+
selectedMolecule?.id === molecule.id
|
| 137 |
+
? 'bg-accent'
|
| 138 |
+
: 'bg-transparent'
|
| 139 |
+
}`}
|
| 140 |
+
>
|
| 141 |
+
<div className="font-medium">{molecule.name}</div>
|
| 142 |
+
<div className="truncate text-xs text-muted-foreground">
|
| 143 |
+
{molecule.smiles}
|
| 144 |
+
</div>
|
| 145 |
+
</button>
|
| 146 |
+
))}
|
| 147 |
+
</div>
|
| 148 |
+
)}
|
| 149 |
+
</div>
|
| 150 |
+
</ScrollArea>
|
| 151 |
+
</Card>
|
| 152 |
+
|
| 153 |
+
{/* Right Panel - Visualization */}
|
| 154 |
+
<div className="flex flex-1 flex-col gap-6">
|
| 155 |
+
{selectedMolecule ? (
|
| 156 |
+
<>
|
| 157 |
+
{/* Molecule Info Card */}
|
| 158 |
+
<Card>
|
| 159 |
+
<CardHeader className="pb-3">
|
| 160 |
+
<div className="flex items-start justify-between">
|
| 161 |
+
<div>
|
| 162 |
+
<CardTitle>{selectedMolecule.name}</CardTitle>
|
| 163 |
+
<CardDescription>
|
| 164 |
+
{selectedMolecule.description}
|
| 165 |
+
</CardDescription>
|
| 166 |
+
</div>
|
| 167 |
+
<Badge variant="outline">
|
| 168 |
+
PubChem: {selectedMolecule.pubchemCid}
|
| 169 |
+
</Badge>
|
| 170 |
+
</div>
|
| 171 |
+
</CardHeader>
|
| 172 |
+
<CardContent>
|
| 173 |
+
<div className="flex items-center gap-2">
|
| 174 |
+
<code className="flex-1 truncate rounded bg-muted px-2 py-1 text-sm">
|
| 175 |
+
{selectedMolecule.smiles}
|
| 176 |
+
</code>
|
| 177 |
+
<Button
|
| 178 |
+
variant="outline"
|
| 179 |
+
size="sm"
|
| 180 |
+
onClick={handleCopySmiles}
|
| 181 |
+
className="shrink-0"
|
| 182 |
+
>
|
| 183 |
+
{copied ? (
|
| 184 |
+
<>
|
| 185 |
+
<Check className="mr-1 size-4" />
|
| 186 |
+
Copied
|
| 187 |
+
</>
|
| 188 |
+
) : (
|
| 189 |
+
<>
|
| 190 |
+
<Copy className="mr-1 size-4" />
|
| 191 |
+
Copy SMILES
|
| 192 |
+
</>
|
| 193 |
+
)}
|
| 194 |
+
</Button>
|
| 195 |
+
</div>
|
| 196 |
+
</CardContent>
|
| 197 |
+
</Card>
|
| 198 |
+
|
| 199 |
+
{/* 2D Structure Card */}
|
| 200 |
+
<Card className="flex-1">
|
| 201 |
+
<CardHeader>
|
| 202 |
+
<CardTitle className="text-lg">2D Structure</CardTitle>
|
| 203 |
+
</CardHeader>
|
| 204 |
+
<CardContent className="flex items-center justify-center">
|
| 205 |
+
<Smiles2DViewer
|
| 206 |
+
smiles={selectedMolecule.smiles}
|
| 207 |
+
width={500}
|
| 208 |
+
height={400}
|
| 209 |
+
/>
|
| 210 |
+
</CardContent>
|
| 211 |
+
</Card>
|
| 212 |
+
</>
|
| 213 |
+
) : (
|
| 214 |
+
<div className="flex flex-1 items-center justify-center">
|
| 215 |
+
<p className="text-muted-foreground">
|
| 216 |
+
Select a molecule to view its structure
|
| 217 |
+
</p>
|
| 218 |
+
</div>
|
| 219 |
+
)}
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
);
|
| 223 |
+
}
|
ui/app/dashboard/molecules-3d/_components/Molecule3DViewer.tsx
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { AlertCircle, RotateCcw } from 'lucide-react';
|
| 4 |
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
| 5 |
+
|
| 6 |
+
import { Button } from '@/components/ui/button';
|
| 7 |
+
import { Card, CardContent } from '@/components/ui/card';
|
| 8 |
+
import {
|
| 9 |
+
Select,
|
| 10 |
+
SelectContent,
|
| 11 |
+
SelectItem,
|
| 12 |
+
SelectTrigger,
|
| 13 |
+
SelectValue,
|
| 14 |
+
} from '@/components/ui/select';
|
| 15 |
+
import { Skeleton } from '@/components/ui/skeleton';
|
| 16 |
+
import type { MoleculeRepresentation } from '@/lib/visualization-types';
|
| 17 |
+
|
| 18 |
+
interface Molecule3DViewerProps {
|
| 19 |
+
sdfData: string;
|
| 20 |
+
width?: number;
|
| 21 |
+
height?: number;
|
| 22 |
+
className?: string;
|
| 23 |
+
initialRepresentation?: MoleculeRepresentation;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 27 |
+
type $3Dmol = any;
|
| 28 |
+
|
| 29 |
+
export function Molecule3DViewer({
|
| 30 |
+
sdfData,
|
| 31 |
+
width = 400,
|
| 32 |
+
height = 400,
|
| 33 |
+
className,
|
| 34 |
+
initialRepresentation = 'stick',
|
| 35 |
+
}: Molecule3DViewerProps) {
|
| 36 |
+
const containerRef = useRef<HTMLDivElement>(null);
|
| 37 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 38 |
+
const viewerRef = useRef<any>(null);
|
| 39 |
+
const [error, setError] = useState<string | null>(null);
|
| 40 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 41 |
+
const [representation, setRepresentation] =
|
| 42 |
+
useState<MoleculeRepresentation>(initialRepresentation);
|
| 43 |
+
const [$3Dmol, set$3Dmol] = useState<$3Dmol | null>(null);
|
| 44 |
+
|
| 45 |
+
// Load 3Dmol.js dynamically
|
| 46 |
+
useEffect(() => {
|
| 47 |
+
const load3Dmol = async () => {
|
| 48 |
+
try {
|
| 49 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 50 |
+
const module = await import('3dmol') as any;
|
| 51 |
+
set$3Dmol(module);
|
| 52 |
+
} catch (err) {
|
| 53 |
+
console.error('Failed to load 3Dmol:', err);
|
| 54 |
+
setError('Failed to load 3D visualization library');
|
| 55 |
+
setIsLoading(false);
|
| 56 |
+
}
|
| 57 |
+
};
|
| 58 |
+
load3Dmol();
|
| 59 |
+
}, []);
|
| 60 |
+
|
| 61 |
+
const getStyleForRepresentation = useCallback(
|
| 62 |
+
(rep: MoleculeRepresentation) => {
|
| 63 |
+
switch (rep) {
|
| 64 |
+
case 'stick':
|
| 65 |
+
return { stick: { radius: 0.15 } };
|
| 66 |
+
case 'sphere':
|
| 67 |
+
return { sphere: { scale: 0.3 } };
|
| 68 |
+
case 'line':
|
| 69 |
+
return { line: { linewidth: 2 } };
|
| 70 |
+
case 'cartoon':
|
| 71 |
+
return { cartoon: {} };
|
| 72 |
+
default:
|
| 73 |
+
return { stick: { radius: 0.15 } };
|
| 74 |
+
}
|
| 75 |
+
},
|
| 76 |
+
[]
|
| 77 |
+
);
|
| 78 |
+
|
| 79 |
+
const initViewer = useCallback(() => {
|
| 80 |
+
if (!containerRef.current || !$3Dmol || !sdfData) return;
|
| 81 |
+
|
| 82 |
+
setIsLoading(true);
|
| 83 |
+
setError(null);
|
| 84 |
+
|
| 85 |
+
try {
|
| 86 |
+
// Clean up existing viewer
|
| 87 |
+
if (viewerRef.current) {
|
| 88 |
+
viewerRef.current.removeAllModels();
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// Create viewer
|
| 92 |
+
const viewer = $3Dmol.createViewer(containerRef.current, {
|
| 93 |
+
backgroundColor: 'transparent',
|
| 94 |
+
});
|
| 95 |
+
viewerRef.current = viewer;
|
| 96 |
+
|
| 97 |
+
// Add molecule
|
| 98 |
+
viewer.addModel(sdfData, 'sdf');
|
| 99 |
+
viewer.setStyle({}, getStyleForRepresentation(representation));
|
| 100 |
+
viewer.zoomTo();
|
| 101 |
+
viewer.render();
|
| 102 |
+
|
| 103 |
+
setIsLoading(false);
|
| 104 |
+
} catch (err) {
|
| 105 |
+
console.error('3D viewer error:', err);
|
| 106 |
+
setError(
|
| 107 |
+
err instanceof Error
|
| 108 |
+
? `Visualization error: ${err.message}`
|
| 109 |
+
: 'Failed to render 3D structure'
|
| 110 |
+
);
|
| 111 |
+
setIsLoading(false);
|
| 112 |
+
}
|
| 113 |
+
}, [$3Dmol, sdfData, representation, getStyleForRepresentation]);
|
| 114 |
+
|
| 115 |
+
useEffect(() => {
|
| 116 |
+
initViewer();
|
| 117 |
+
}, [$3Dmol, sdfData, initViewer]);
|
| 118 |
+
|
| 119 |
+
// Update representation
|
| 120 |
+
useEffect(() => {
|
| 121 |
+
if (!viewerRef.current) return;
|
| 122 |
+
try {
|
| 123 |
+
viewerRef.current.setStyle({}, getStyleForRepresentation(representation));
|
| 124 |
+
viewerRef.current.render();
|
| 125 |
+
} catch (err) {
|
| 126 |
+
console.error('Style update error:', err);
|
| 127 |
+
}
|
| 128 |
+
}, [representation, getStyleForRepresentation]);
|
| 129 |
+
|
| 130 |
+
const handleResetCamera = useCallback(() => {
|
| 131 |
+
if (!viewerRef.current) return;
|
| 132 |
+
viewerRef.current.zoomTo();
|
| 133 |
+
viewerRef.current.render();
|
| 134 |
+
}, []);
|
| 135 |
+
|
| 136 |
+
if (error) {
|
| 137 |
+
return (
|
| 138 |
+
<Card className={`border-destructive bg-destructive/10 ${className}`}>
|
| 139 |
+
<CardContent className="flex items-center gap-3 p-4">
|
| 140 |
+
<AlertCircle className="size-5 text-destructive" />
|
| 141 |
+
<div className="flex flex-col">
|
| 142 |
+
<span className="text-sm font-medium text-destructive">
|
| 143 |
+
3D Visualization Error
|
| 144 |
+
</span>
|
| 145 |
+
<span className="text-xs text-muted-foreground">{error}</span>
|
| 146 |
+
</div>
|
| 147 |
+
</CardContent>
|
| 148 |
+
</Card>
|
| 149 |
+
);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
return (
|
| 153 |
+
<div className={`flex flex-col gap-3 ${className}`}>
|
| 154 |
+
<div className="flex items-center gap-2">
|
| 155 |
+
<Select
|
| 156 |
+
value={representation}
|
| 157 |
+
onValueChange={(value) =>
|
| 158 |
+
setRepresentation(value as MoleculeRepresentation)
|
| 159 |
+
}
|
| 160 |
+
>
|
| 161 |
+
<SelectTrigger className="w-32">
|
| 162 |
+
<SelectValue placeholder="Style" />
|
| 163 |
+
</SelectTrigger>
|
| 164 |
+
<SelectContent>
|
| 165 |
+
<SelectItem value="stick">Stick</SelectItem>
|
| 166 |
+
<SelectItem value="sphere">Sphere</SelectItem>
|
| 167 |
+
<SelectItem value="line">Line</SelectItem>
|
| 168 |
+
</SelectContent>
|
| 169 |
+
</Select>
|
| 170 |
+
<Button variant="outline" size="sm" onClick={handleResetCamera}>
|
| 171 |
+
<RotateCcw className="mr-1 size-4" />
|
| 172 |
+
Reset
|
| 173 |
+
</Button>
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
<div className="relative">
|
| 177 |
+
{isLoading && (
|
| 178 |
+
<Skeleton
|
| 179 |
+
className="absolute inset-0 z-10"
|
| 180 |
+
style={{ width, height }}
|
| 181 |
+
/>
|
| 182 |
+
)}
|
| 183 |
+
<div
|
| 184 |
+
ref={containerRef}
|
| 185 |
+
style={{ width, height }}
|
| 186 |
+
className={`rounded-lg border bg-background ${isLoading ? 'opacity-0' : 'opacity-100'} transition-opacity duration-200`}
|
| 187 |
+
/>
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
);
|
| 191 |
+
}
|
ui/app/dashboard/molecules-3d/page.tsx
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { Check, Copy, Search } from 'lucide-react';
|
| 4 |
+
import dynamic from 'next/dynamic';
|
| 5 |
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
| 6 |
+
|
| 7 |
+
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
| 8 |
+
import { Badge } from '@/components/ui/badge';
|
| 9 |
+
import { Button } from '@/components/ui/button';
|
| 10 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
| 11 |
+
import { Input } from '@/components/ui/input';
|
| 12 |
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
| 13 |
+
import { Separator } from '@/components/ui/separator';
|
| 14 |
+
import { Skeleton } from '@/components/ui/skeleton';
|
| 15 |
+
import { getMolecules, getMoleculeSDF } from '@/lib/visualization-api';
|
| 16 |
+
import type { Molecule } from '@/lib/visualization-types';
|
| 17 |
+
|
| 18 |
+
// Dynamic import of the 3D viewer to prevent SSR issues
|
| 19 |
+
const Molecule3DViewer = dynamic(
|
| 20 |
+
() =>
|
| 21 |
+
import('./_components/Molecule3DViewer').then((mod) => mod.Molecule3DViewer),
|
| 22 |
+
{
|
| 23 |
+
ssr: false,
|
| 24 |
+
loading: () => <Skeleton className="size-[400px]" />,
|
| 25 |
+
}
|
| 26 |
+
);
|
| 27 |
+
|
| 28 |
+
export default function Molecules3DPage() {
|
| 29 |
+
const [molecules, setMolecules] = useState<Molecule[]>([]);
|
| 30 |
+
const [selectedMolecule, setSelectedMolecule] = useState<Molecule | null>(null);
|
| 31 |
+
const [sdfData, setSdfData] = useState<string | null>(null);
|
| 32 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 33 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 34 |
+
const [isSdfLoading, setIsSdfLoading] = useState(false);
|
| 35 |
+
const [error, setError] = useState<string | null>(null);
|
| 36 |
+
const [sdfError, setSdfError] = useState<string | null>(null);
|
| 37 |
+
const [copied, setCopied] = useState(false);
|
| 38 |
+
|
| 39 |
+
// Load molecules list
|
| 40 |
+
useEffect(() => {
|
| 41 |
+
const loadMolecules = async () => {
|
| 42 |
+
try {
|
| 43 |
+
setIsLoading(true);
|
| 44 |
+
const data = await getMolecules();
|
| 45 |
+
setMolecules(data);
|
| 46 |
+
if (data.length > 0) {
|
| 47 |
+
setSelectedMolecule(data[0]);
|
| 48 |
+
}
|
| 49 |
+
} catch (err) {
|
| 50 |
+
setError(
|
| 51 |
+
err instanceof Error ? err.message : 'Failed to load molecules'
|
| 52 |
+
);
|
| 53 |
+
} finally {
|
| 54 |
+
setIsLoading(false);
|
| 55 |
+
}
|
| 56 |
+
};
|
| 57 |
+
loadMolecules();
|
| 58 |
+
}, []);
|
| 59 |
+
|
| 60 |
+
// Load SDF when molecule changes
|
| 61 |
+
useEffect(() => {
|
| 62 |
+
if (!selectedMolecule) {
|
| 63 |
+
setSdfData(null);
|
| 64 |
+
return;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
const loadSdf = async () => {
|
| 68 |
+
try {
|
| 69 |
+
setIsSdfLoading(true);
|
| 70 |
+
setSdfError(null);
|
| 71 |
+
const data = await getMoleculeSDF(selectedMolecule.id);
|
| 72 |
+
setSdfData(data);
|
| 73 |
+
} catch (err) {
|
| 74 |
+
setSdfError(
|
| 75 |
+
err instanceof Error ? err.message : 'Failed to load 3D structure'
|
| 76 |
+
);
|
| 77 |
+
setSdfData(null);
|
| 78 |
+
} finally {
|
| 79 |
+
setIsSdfLoading(false);
|
| 80 |
+
}
|
| 81 |
+
};
|
| 82 |
+
loadSdf();
|
| 83 |
+
}, [selectedMolecule]);
|
| 84 |
+
|
| 85 |
+
const filteredMolecules = useMemo(() => {
|
| 86 |
+
if (!searchQuery.trim()) return molecules;
|
| 87 |
+
const query = searchQuery.toLowerCase();
|
| 88 |
+
return molecules.filter(
|
| 89 |
+
(m) =>
|
| 90 |
+
m.name.toLowerCase().includes(query) ||
|
| 91 |
+
m.smiles.toLowerCase().includes(query)
|
| 92 |
+
);
|
| 93 |
+
}, [molecules, searchQuery]);
|
| 94 |
+
|
| 95 |
+
const handleCopySmiles = useCallback(async () => {
|
| 96 |
+
if (!selectedMolecule) return;
|
| 97 |
+
try {
|
| 98 |
+
await navigator.clipboard.writeText(selectedMolecule.smiles);
|
| 99 |
+
setCopied(true);
|
| 100 |
+
setTimeout(() => setCopied(false), 2000);
|
| 101 |
+
} catch {
|
| 102 |
+
const textArea = document.createElement('textarea');
|
| 103 |
+
textArea.value = selectedMolecule.smiles;
|
| 104 |
+
document.body.appendChild(textArea);
|
| 105 |
+
textArea.select();
|
| 106 |
+
document.execCommand('copy');
|
| 107 |
+
document.body.removeChild(textArea);
|
| 108 |
+
setCopied(true);
|
| 109 |
+
setTimeout(() => setCopied(false), 2000);
|
| 110 |
+
}
|
| 111 |
+
}, [selectedMolecule]);
|
| 112 |
+
|
| 113 |
+
if (error) {
|
| 114 |
+
return (
|
| 115 |
+
<div className="flex h-full items-center justify-center">
|
| 116 |
+
<Card className="max-w-md">
|
| 117 |
+
<CardHeader>
|
| 118 |
+
<CardTitle className="text-destructive">Error</CardTitle>
|
| 119 |
+
<CardDescription>{error}</CardDescription>
|
| 120 |
+
</CardHeader>
|
| 121 |
+
</Card>
|
| 122 |
+
</div>
|
| 123 |
+
);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
return (
|
| 127 |
+
<div className="flex h-full gap-6 p-6">
|
| 128 |
+
{/* Left Panel - Molecule List */}
|
| 129 |
+
<Card className="w-80 shrink-0">
|
| 130 |
+
<CardHeader className="pb-3">
|
| 131 |
+
<CardTitle className="text-lg">Molecules</CardTitle>
|
| 132 |
+
<CardDescription>Select a molecule for 3D view</CardDescription>
|
| 133 |
+
</CardHeader>
|
| 134 |
+
<CardContent className="pb-3">
|
| 135 |
+
<div className="relative">
|
| 136 |
+
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
| 137 |
+
<Input
|
| 138 |
+
placeholder="Search molecules..."
|
| 139 |
+
className="pl-8"
|
| 140 |
+
value={searchQuery}
|
| 141 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 142 |
+
/>
|
| 143 |
+
</div>
|
| 144 |
+
</CardContent>
|
| 145 |
+
<Separator />
|
| 146 |
+
<ScrollArea className="h-[calc(100vh-280px)]">
|
| 147 |
+
<div className="p-2">
|
| 148 |
+
{isLoading ? (
|
| 149 |
+
<div className="space-y-2">
|
| 150 |
+
{[1, 2, 3].map((i) => (
|
| 151 |
+
<Skeleton key={i} className="h-16 w-full" />
|
| 152 |
+
))}
|
| 153 |
+
</div>
|
| 154 |
+
) : filteredMolecules.length === 0 ? (
|
| 155 |
+
<p className="p-4 text-center text-sm text-muted-foreground">
|
| 156 |
+
No molecules found
|
| 157 |
+
</p>
|
| 158 |
+
) : (
|
| 159 |
+
<div className="space-y-1">
|
| 160 |
+
{filteredMolecules.map((molecule) => (
|
| 161 |
+
<button
|
| 162 |
+
key={molecule.id}
|
| 163 |
+
onClick={() => setSelectedMolecule(molecule)}
|
| 164 |
+
className={`w-full rounded-lg p-3 text-left transition-colors hover:bg-accent ${
|
| 165 |
+
selectedMolecule?.id === molecule.id
|
| 166 |
+
? 'bg-accent'
|
| 167 |
+
: 'bg-transparent'
|
| 168 |
+
}`}
|
| 169 |
+
>
|
| 170 |
+
<div className="font-medium">{molecule.name}</div>
|
| 171 |
+
<div className="truncate text-xs text-muted-foreground">
|
| 172 |
+
{molecule.smiles}
|
| 173 |
+
</div>
|
| 174 |
+
</button>
|
| 175 |
+
))}
|
| 176 |
+
</div>
|
| 177 |
+
)}
|
| 178 |
+
</div>
|
| 179 |
+
</ScrollArea>
|
| 180 |
+
</Card>
|
| 181 |
+
|
| 182 |
+
{/* Right Panel - 3D Visualization */}
|
| 183 |
+
<div className="flex flex-1 flex-col gap-6">
|
| 184 |
+
{selectedMolecule ? (
|
| 185 |
+
<>
|
| 186 |
+
{/* Molecule Info Card */}
|
| 187 |
+
<Card>
|
| 188 |
+
<CardHeader className="pb-3">
|
| 189 |
+
<div className="flex items-start justify-between">
|
| 190 |
+
<div>
|
| 191 |
+
<CardTitle>{selectedMolecule.name}</CardTitle>
|
| 192 |
+
<CardDescription>
|
| 193 |
+
{selectedMolecule.description}
|
| 194 |
+
</CardDescription>
|
| 195 |
+
</div>
|
| 196 |
+
<Badge variant="outline">
|
| 197 |
+
PubChem: {selectedMolecule.pubchemCid}
|
| 198 |
+
</Badge>
|
| 199 |
+
</div>
|
| 200 |
+
</CardHeader>
|
| 201 |
+
<CardContent>
|
| 202 |
+
<div className="flex items-center gap-2">
|
| 203 |
+
<code className="flex-1 truncate rounded bg-muted px-2 py-1 text-sm">
|
| 204 |
+
{selectedMolecule.smiles}
|
| 205 |
+
</code>
|
| 206 |
+
<Button
|
| 207 |
+
variant="outline"
|
| 208 |
+
size="sm"
|
| 209 |
+
onClick={handleCopySmiles}
|
| 210 |
+
className="shrink-0"
|
| 211 |
+
>
|
| 212 |
+
{copied ? (
|
| 213 |
+
<>
|
| 214 |
+
<Check className="mr-1 size-4" />
|
| 215 |
+
Copied
|
| 216 |
+
</>
|
| 217 |
+
) : (
|
| 218 |
+
<>
|
| 219 |
+
<Copy className="mr-1 size-4" />
|
| 220 |
+
Copy SMILES
|
| 221 |
+
</>
|
| 222 |
+
)}
|
| 223 |
+
</Button>
|
| 224 |
+
</div>
|
| 225 |
+
</CardContent>
|
| 226 |
+
</Card>
|
| 227 |
+
|
| 228 |
+
{/* 3D Structure Card */}
|
| 229 |
+
<Card className="flex-1">
|
| 230 |
+
<CardHeader>
|
| 231 |
+
<CardTitle className="text-lg">3D Structure</CardTitle>
|
| 232 |
+
<CardDescription>
|
| 233 |
+
Rotate: click + drag • Zoom: scroll • Pan: right-click + drag
|
| 234 |
+
</CardDescription>
|
| 235 |
+
</CardHeader>
|
| 236 |
+
<CardContent className="flex items-center justify-center">
|
| 237 |
+
{isSdfLoading ? (
|
| 238 |
+
<Skeleton className="size-[400px]" />
|
| 239 |
+
) : sdfError ? (
|
| 240 |
+
<Alert variant="destructive" className="max-w-md">
|
| 241 |
+
<AlertTitle>Failed to load 3D structure</AlertTitle>
|
| 242 |
+
<AlertDescription>{sdfError}</AlertDescription>
|
| 243 |
+
</Alert>
|
| 244 |
+
) : sdfData ? (
|
| 245 |
+
<Molecule3DViewer sdfData={sdfData} width={500} height={400} />
|
| 246 |
+
) : (
|
| 247 |
+
<p className="text-muted-foreground">
|
| 248 |
+
No 3D structure available
|
| 249 |
+
</p>
|
| 250 |
+
)}
|
| 251 |
+
</CardContent>
|
| 252 |
+
</Card>
|
| 253 |
+
</>
|
| 254 |
+
) : (
|
| 255 |
+
<div className="flex flex-1 items-center justify-center">
|
| 256 |
+
<p className="text-muted-foreground">
|
| 257 |
+
Select a molecule to view its 3D structure
|
| 258 |
+
</p>
|
| 259 |
+
</div>
|
| 260 |
+
)}
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
);
|
| 264 |
+
}
|
ui/app/dashboard/page.tsx
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { ArrowUp, Database, FileText, Search, Sparkles, Zap } from "lucide-react"
|
| 4 |
+
import Link from "next/link"
|
| 5 |
+
|
| 6 |
+
import { Badge } from "@/components/ui/badge"
|
| 7 |
+
import { Button } from "@/components/ui/button"
|
| 8 |
+
import { Card, CardContent } from "@/components/ui/card"
|
| 9 |
+
import { Separator } from "@/components/ui/separator"
|
| 10 |
+
|
| 11 |
+
export default function DashboardHome() {
|
| 12 |
+
return (
|
| 13 |
+
<div className="space-y-8 animate-in fade-in duration-500 p-8">
|
| 14 |
+
{/* Hero Section */}
|
| 15 |
+
<div className="flex flex-col lg:flex-row gap-6">
|
| 16 |
+
<div className="flex-1 rounded-2xl bg-gradient-to-br from-primary/10 via-background to-background p-8 border">
|
| 17 |
+
<Badge variant="secondary" className="mb-4">New • BioFlow 2.0</Badge>
|
| 18 |
+
<h1 className="text-4xl font-bold tracking-tight mb-4">AI-Powered Drug Discovery</h1>
|
| 19 |
+
<p className="text-lg text-muted-foreground mb-6 max-w-xl">
|
| 20 |
+
Run discovery pipelines, predict binding, and surface evidence in one streamlined workspace.
|
| 21 |
+
</p>
|
| 22 |
+
<div className="flex gap-2 mb-6">
|
| 23 |
+
<Badge variant="outline" className="bg-primary/5 border-primary/20 text-primary">Model-aware search</Badge>
|
| 24 |
+
<Badge variant="outline" className="bg-green-500/10 border-green-500/20 text-green-700 dark:text-green-400">Evidence-linked</Badge>
|
| 25 |
+
<Badge variant="outline" className="bg-amber-500/10 border-amber-500/20 text-amber-700 dark:text-amber-400">Fast iteration</Badge>
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
<div className="flex gap-4">
|
| 29 |
+
<Link href="/dashboard/discovery">
|
| 30 |
+
<Button size="lg" className="font-semibold">
|
| 31 |
+
Start Discovery
|
| 32 |
+
</Button>
|
| 33 |
+
</Link>
|
| 34 |
+
<Link href="/dashboard/explorer">
|
| 35 |
+
<Button size="lg" variant="outline">
|
| 36 |
+
Explore Data
|
| 37 |
+
</Button>
|
| 38 |
+
</Link>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<div className="lg:w-[350px]">
|
| 43 |
+
<Card className="h-full">
|
| 44 |
+
<CardContent className="p-6 flex flex-col justify-between h-full">
|
| 45 |
+
<div>
|
| 46 |
+
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-2">Today</div>
|
| 47 |
+
<div className="text-4xl font-bold mb-2">156 Discoveries</div>
|
| 48 |
+
<div className="text-sm text-green-600 font-medium flex items-center gap-1">
|
| 49 |
+
<ArrowUp className="h-4 w-4" />
|
| 50 |
+
+12% vs last week
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<Separator className="my-4" />
|
| 55 |
+
|
| 56 |
+
<div className="space-y-2">
|
| 57 |
+
<div className="flex items-center justify-between text-sm">
|
| 58 |
+
<span className="flex items-center gap-2">
|
| 59 |
+
<span className="h-2 w-2 rounded-full bg-primary"></span>
|
| 60 |
+
Discovery
|
| 61 |
+
</span>
|
| 62 |
+
<span className="font-mono font-medium">64</span>
|
| 63 |
+
</div>
|
| 64 |
+
<div className="flex items-center justify-between text-sm">
|
| 65 |
+
<span className="flex items-center gap-2">
|
| 66 |
+
<span className="h-2 w-2 rounded-full bg-green-500"></span>
|
| 67 |
+
Prediction
|
| 68 |
+
</span>
|
| 69 |
+
<span className="font-mono font-medium">42</span>
|
| 70 |
+
</div>
|
| 71 |
+
<div className="flex items-center justify-between text-sm">
|
| 72 |
+
<span className="flex items-center gap-2">
|
| 73 |
+
<span className="h-2 w-2 rounded-full bg-amber-500"></span>
|
| 74 |
+
Evidence
|
| 75 |
+
</span>
|
| 76 |
+
<span className="font-mono font-medium">50</span>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
</CardContent>
|
| 80 |
+
</Card>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
{/* Metrics Row */}
|
| 85 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 86 |
+
{[
|
| 87 |
+
{ label: "Molecules", value: "12.5M", icon: "🧪", change: "+2.3%", color: "text-blue-500" },
|
| 88 |
+
{ label: "Proteins", value: "847K", icon: "🧬", change: "+1.8%", color: "text-cyan-500" },
|
| 89 |
+
{ label: "Papers", value: "1.2M", icon: "📚", change: "+5.2%", color: "text-emerald-500" },
|
| 90 |
+
{ label: "Discoveries", value: "156", icon: "✨", change: "+12%", color: "text-amber-500" }
|
| 91 |
+
].map((metric, i) => (
|
| 92 |
+
<Card key={i}>
|
| 93 |
+
<CardContent className="p-6">
|
| 94 |
+
<div className="flex justify-between items-start mb-2">
|
| 95 |
+
<div className="text-sm font-medium text-muted-foreground">{metric.label}</div>
|
| 96 |
+
<div className="text-lg">{metric.icon}</div>
|
| 97 |
+
</div>
|
| 98 |
+
<div className="text-2xl font-bold mb-1">{metric.value}</div>
|
| 99 |
+
<div className="text-xs font-medium flex items-center gap-1 text-green-500">
|
| 100 |
+
<ArrowUp className="h-3 w-3" />
|
| 101 |
+
{metric.change}
|
| 102 |
+
</div>
|
| 103 |
+
</CardContent>
|
| 104 |
+
</Card>
|
| 105 |
+
))}
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
{/* Quick Actions */}
|
| 109 |
+
<div className="pt-4">
|
| 110 |
+
<div className="flex items-center gap-2 mb-4">
|
| 111 |
+
<Zap className="h-5 w-5 text-amber-500" />
|
| 112 |
+
<h2 className="text-xl font-semibold">Quick Actions</h2>
|
| 113 |
+
</div>
|
| 114 |
+
|
| 115 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
| 116 |
+
<Link href="/dashboard/molecules-2d" className="block">
|
| 117 |
+
<Card className="hover:bg-accent/50 transition-colors cursor-pointer h-full">
|
| 118 |
+
<CardContent className="p-6 flex flex-col items-center text-center gap-3">
|
| 119 |
+
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
| 120 |
+
<Search className="h-5 w-5" />
|
| 121 |
+
</div>
|
| 122 |
+
<div>
|
| 123 |
+
<div className="font-semibold">Molecules 2D</div>
|
| 124 |
+
<div className="text-sm text-muted-foreground">View 2D structures</div>
|
| 125 |
+
</div>
|
| 126 |
+
</CardContent>
|
| 127 |
+
</Card>
|
| 128 |
+
</Link>
|
| 129 |
+
<Link href="/dashboard/molecules-3d" className="block">
|
| 130 |
+
<Card className="hover:bg-accent/50 transition-colors cursor-pointer h-full">
|
| 131 |
+
<CardContent className="p-6 flex flex-col items-center text-center gap-3">
|
| 132 |
+
<div className="h-10 w-10 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-500">
|
| 133 |
+
<Database className="h-5 w-5" />
|
| 134 |
+
</div>
|
| 135 |
+
<div>
|
| 136 |
+
<div className="font-semibold">Molecules 3D</div>
|
| 137 |
+
<div className="text-sm text-muted-foreground">View 3D structures</div>
|
| 138 |
+
</div>
|
| 139 |
+
</CardContent>
|
| 140 |
+
</Card>
|
| 141 |
+
</Link>
|
| 142 |
+
<Link href="/dashboard/proteins-3d" className="block">
|
| 143 |
+
<Card className="hover:bg-accent/50 transition-colors cursor-pointer h-full">
|
| 144 |
+
<CardContent className="p-6 flex flex-col items-center text-center gap-3">
|
| 145 |
+
<div className="h-10 w-10 rounded-full bg-purple-500/10 flex items-center justify-center text-purple-500">
|
| 146 |
+
<FileText className="h-5 w-5" />
|
| 147 |
+
</div>
|
| 148 |
+
<div>
|
| 149 |
+
<div className="font-semibold">Proteins 3D</div>
|
| 150 |
+
<div className="text-sm text-muted-foreground">View protein structures</div>
|
| 151 |
+
</div>
|
| 152 |
+
</CardContent>
|
| 153 |
+
</Card>
|
| 154 |
+
</Link>
|
| 155 |
+
<Link href="/dashboard/discovery" className="block">
|
| 156 |
+
<Card className="hover:bg-accent/50 transition-colors cursor-pointer h-full">
|
| 157 |
+
<CardContent className="p-6 flex flex-col items-center text-center gap-3">
|
| 158 |
+
<div className="h-10 w-10 rounded-full bg-slate-500/10 flex items-center justify-center text-slate-500">
|
| 159 |
+
<Sparkles className="h-5 w-5" />
|
| 160 |
+
</div>
|
| 161 |
+
<div>
|
| 162 |
+
<div className="font-semibold">Discovery</div>
|
| 163 |
+
<div className="text-sm text-muted-foreground">Run predictions</div>
|
| 164 |
+
</div>
|
| 165 |
+
</CardContent>
|
| 166 |
+
</Card>
|
| 167 |
+
</Link>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
)
|
| 172 |
+
}
|
ui/app/dashboard/proteins-3d/_components/ProteinViewer.tsx
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { AlertCircle, RotateCcw } from 'lucide-react';
|
| 4 |
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
| 5 |
+
|
| 6 |
+
import { Button } from '@/components/ui/button';
|
| 7 |
+
import { Card, CardContent } from '@/components/ui/card';
|
| 8 |
+
import {
|
| 9 |
+
Select,
|
| 10 |
+
SelectContent,
|
| 11 |
+
SelectItem,
|
| 12 |
+
SelectTrigger,
|
| 13 |
+
SelectValue,
|
| 14 |
+
} from '@/components/ui/select';
|
| 15 |
+
import { Skeleton } from '@/components/ui/skeleton';
|
| 16 |
+
import { getProteinPdbUrl } from '@/lib/visualization-api';
|
| 17 |
+
import type { ProteinRepresentation } from '@/lib/visualization-types';
|
| 18 |
+
|
| 19 |
+
interface ProteinViewerProps {
|
| 20 |
+
pdbId: string;
|
| 21 |
+
width?: number;
|
| 22 |
+
height?: number;
|
| 23 |
+
className?: string;
|
| 24 |
+
initialRepresentation?: ProteinRepresentation;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 28 |
+
type $3Dmol = any;
|
| 29 |
+
|
| 30 |
+
export function ProteinViewer({
|
| 31 |
+
pdbId,
|
| 32 |
+
width = 500,
|
| 33 |
+
height = 500,
|
| 34 |
+
className,
|
| 35 |
+
initialRepresentation = 'cartoon',
|
| 36 |
+
}: ProteinViewerProps) {
|
| 37 |
+
const containerRef = useRef<HTMLDivElement>(null);
|
| 38 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 39 |
+
const viewerRef = useRef<any>(null);
|
| 40 |
+
const [error, setError] = useState<string | null>(null);
|
| 41 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 42 |
+
const [representation, setRepresentation] =
|
| 43 |
+
useState<ProteinRepresentation>(initialRepresentation);
|
| 44 |
+
const [$3Dmol, set$3Dmol] = useState<$3Dmol | null>(null);
|
| 45 |
+
const [pdbData, setPdbData] = useState<string | null>(null);
|
| 46 |
+
|
| 47 |
+
// Load 3Dmol.js dynamically
|
| 48 |
+
useEffect(() => {
|
| 49 |
+
const load3Dmol = async () => {
|
| 50 |
+
try {
|
| 51 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 52 |
+
const module = await import('3dmol') as any;
|
| 53 |
+
set$3Dmol(module);
|
| 54 |
+
} catch (err) {
|
| 55 |
+
console.error('Failed to load 3Dmol:', err);
|
| 56 |
+
setError('Failed to load 3D visualization library');
|
| 57 |
+
setIsLoading(false);
|
| 58 |
+
}
|
| 59 |
+
};
|
| 60 |
+
load3Dmol();
|
| 61 |
+
}, []);
|
| 62 |
+
|
| 63 |
+
// Fetch PDB data when pdbId changes
|
| 64 |
+
useEffect(() => {
|
| 65 |
+
if (!pdbId) return;
|
| 66 |
+
|
| 67 |
+
const fetchPdb = async () => {
|
| 68 |
+
try {
|
| 69 |
+
setIsLoading(true);
|
| 70 |
+
setError(null);
|
| 71 |
+
const pdbUrl = getProteinPdbUrl(pdbId);
|
| 72 |
+
const response = await fetch(pdbUrl);
|
| 73 |
+
|
| 74 |
+
if (!response.ok) {
|
| 75 |
+
throw new Error(`Failed to fetch PDB: ${response.statusText}`);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const data = await response.text();
|
| 79 |
+
setPdbData(data);
|
| 80 |
+
} catch (err) {
|
| 81 |
+
console.error('Failed to fetch PDB:', err);
|
| 82 |
+
setError(
|
| 83 |
+
err instanceof Error ? err.message : 'Failed to load PDB data'
|
| 84 |
+
);
|
| 85 |
+
setIsLoading(false);
|
| 86 |
+
}
|
| 87 |
+
};
|
| 88 |
+
fetchPdb();
|
| 89 |
+
}, [pdbId]);
|
| 90 |
+
|
| 91 |
+
const getStyleForRepresentation = useCallback(
|
| 92 |
+
(rep: ProteinRepresentation) => {
|
| 93 |
+
switch (rep) {
|
| 94 |
+
case 'cartoon':
|
| 95 |
+
return { cartoon: { color: 'spectrum' } };
|
| 96 |
+
case 'ball-and-stick':
|
| 97 |
+
return { stick: { radius: 0.15 }, sphere: { scale: 0.25 } };
|
| 98 |
+
case 'surface':
|
| 99 |
+
return { surface: {} }; // Surface handled via addSurface
|
| 100 |
+
case 'ribbon':
|
| 101 |
+
return { cartoon: { style: 'trace', color: 'spectrum' } };
|
| 102 |
+
default:
|
| 103 |
+
return { cartoon: { color: 'spectrum' } };
|
| 104 |
+
}
|
| 105 |
+
},
|
| 106 |
+
[]
|
| 107 |
+
);
|
| 108 |
+
|
| 109 |
+
const initViewer = useCallback(() => {
|
| 110 |
+
if (!containerRef.current || !$3Dmol || !pdbData) return;
|
| 111 |
+
|
| 112 |
+
setIsLoading(true);
|
| 113 |
+
setError(null);
|
| 114 |
+
|
| 115 |
+
try {
|
| 116 |
+
// Clean up existing viewer
|
| 117 |
+
if (viewerRef.current) {
|
| 118 |
+
viewerRef.current.removeAllModels();
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// Create viewer
|
| 122 |
+
const viewer = $3Dmol.createViewer(containerRef.current, {
|
| 123 |
+
backgroundColor: '#0a0a0a',
|
| 124 |
+
});
|
| 125 |
+
viewerRef.current = viewer;
|
| 126 |
+
|
| 127 |
+
// Add protein structure
|
| 128 |
+
viewer.addModel(pdbData, 'pdb');
|
| 129 |
+
|
| 130 |
+
if (representation === 'surface') {
|
| 131 |
+
viewer.setStyle({}, { cartoon: { color: 'spectrum' } }); // Base style
|
| 132 |
+
viewer.addSurface($3Dmol.SurfaceType.VDW, {
|
| 133 |
+
opacity: 0.85,
|
| 134 |
+
color: 'spectrum',
|
| 135 |
+
}, {}, {});
|
| 136 |
+
} else {
|
| 137 |
+
viewer.setStyle({}, getStyleForRepresentation(representation));
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
viewer.zoomTo();
|
| 141 |
+
viewer.render();
|
| 142 |
+
|
| 143 |
+
setIsLoading(false);
|
| 144 |
+
} catch (err) {
|
| 145 |
+
console.error('3D viewer error:', err);
|
| 146 |
+
setError(
|
| 147 |
+
err instanceof Error
|
| 148 |
+
? `Visualization error: ${err.message}`
|
| 149 |
+
: 'Failed to render 3D structure'
|
| 150 |
+
);
|
| 151 |
+
setIsLoading(false);
|
| 152 |
+
}
|
| 153 |
+
}, [$3Dmol, pdbData, representation, getStyleForRepresentation]);
|
| 154 |
+
|
| 155 |
+
useEffect(() => {
|
| 156 |
+
initViewer();
|
| 157 |
+
}, [$3Dmol, pdbData, initViewer]);
|
| 158 |
+
|
| 159 |
+
// Update representation
|
| 160 |
+
useEffect(() => {
|
| 161 |
+
if (!viewerRef.current || !pdbData || !$3Dmol) return;
|
| 162 |
+
try {
|
| 163 |
+
viewerRef.current.removeAllSurfaces();
|
| 164 |
+
|
| 165 |
+
if (representation === 'surface') {
|
| 166 |
+
viewerRef.current.setStyle({}, { cartoon: { color: 'spectrum', opacity: 0.5 } });
|
| 167 |
+
viewerRef.current.addSurface($3Dmol.SurfaceType.VDW, {
|
| 168 |
+
opacity: 0.85,
|
| 169 |
+
color: 'spectrum',
|
| 170 |
+
}, {}, {});
|
| 171 |
+
} else {
|
| 172 |
+
viewerRef.current.setStyle({}, getStyleForRepresentation(representation));
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
viewerRef.current.render();
|
| 176 |
+
} catch (err) {
|
| 177 |
+
console.error('Style update error:', err);
|
| 178 |
+
}
|
| 179 |
+
}, [representation, getStyleForRepresentation, pdbData, $3Dmol]);
|
| 180 |
+
|
| 181 |
+
const handleResetCamera = useCallback(() => {
|
| 182 |
+
if (!viewerRef.current) return;
|
| 183 |
+
viewerRef.current.zoomTo();
|
| 184 |
+
viewerRef.current.render();
|
| 185 |
+
}, []);
|
| 186 |
+
|
| 187 |
+
if (error) {
|
| 188 |
+
return (
|
| 189 |
+
<Card className={`border-destructive bg-destructive/10 ${className}`}>
|
| 190 |
+
<CardContent className="flex items-center gap-3 p-4">
|
| 191 |
+
<AlertCircle className="size-5 text-destructive" />
|
| 192 |
+
<div className="flex flex-col">
|
| 193 |
+
<span className="text-sm font-medium text-destructive">
|
| 194 |
+
Protein Visualization Error
|
| 195 |
+
</span>
|
| 196 |
+
<span className="text-xs text-muted-foreground">{error}</span>
|
| 197 |
+
</div>
|
| 198 |
+
</CardContent>
|
| 199 |
+
</Card>
|
| 200 |
+
);
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
return (
|
| 204 |
+
<div className={`flex flex-col gap-3 ${className}`}>
|
| 205 |
+
<div className="flex items-center gap-2">
|
| 206 |
+
<Select
|
| 207 |
+
value={representation}
|
| 208 |
+
onValueChange={(value) =>
|
| 209 |
+
setRepresentation(value as ProteinRepresentation)
|
| 210 |
+
}
|
| 211 |
+
>
|
| 212 |
+
<SelectTrigger className="w-40">
|
| 213 |
+
<SelectValue placeholder="Representation" />
|
| 214 |
+
</SelectTrigger>
|
| 215 |
+
<SelectContent>
|
| 216 |
+
<SelectItem value="cartoon">Cartoon</SelectItem>
|
| 217 |
+
<SelectItem value="ball-and-stick">Ball & Stick</SelectItem>
|
| 218 |
+
<SelectItem value="surface">Surface</SelectItem>
|
| 219 |
+
<SelectItem value="ribbon">Ribbon</SelectItem>
|
| 220 |
+
</SelectContent>
|
| 221 |
+
</Select>
|
| 222 |
+
<Button variant="outline" size="sm" onClick={handleResetCamera}>
|
| 223 |
+
<RotateCcw className="mr-1 size-4" />
|
| 224 |
+
Reset
|
| 225 |
+
</Button>
|
| 226 |
+
</div>
|
| 227 |
+
|
| 228 |
+
<div className="relative">
|
| 229 |
+
{isLoading && (
|
| 230 |
+
<Skeleton
|
| 231 |
+
className="absolute inset-0 z-10"
|
| 232 |
+
style={{ width, height }}
|
| 233 |
+
/>
|
| 234 |
+
)}
|
| 235 |
+
<div
|
| 236 |
+
ref={containerRef}
|
| 237 |
+
style={{ width, height }}
|
| 238 |
+
className={`rounded-lg border bg-background ${isLoading ? 'opacity-0' : 'opacity-100'} transition-opacity duration-200`}
|
| 239 |
+
/>
|
| 240 |
+
</div>
|
| 241 |
+
</div>
|
| 242 |
+
);
|
| 243 |
+
}
|
ui/app/dashboard/proteins-3d/page.tsx
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { ExternalLink, Search } from 'lucide-react';
|
| 4 |
+
import dynamic from 'next/dynamic';
|
| 5 |
+
import { useEffect, useMemo, useState } from 'react';
|
| 6 |
+
|
| 7 |
+
import { Badge } from '@/components/ui/badge';
|
| 8 |
+
import { Button } from '@/components/ui/button';
|
| 9 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
| 10 |
+
import { Input } from '@/components/ui/input';
|
| 11 |
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
| 12 |
+
import { Separator } from '@/components/ui/separator';
|
| 13 |
+
import { Skeleton } from '@/components/ui/skeleton';
|
| 14 |
+
import { getProteins, getProteinPdbUrl } from '@/lib/visualization-api';
|
| 15 |
+
import type { Protein } from '@/lib/visualization-types';
|
| 16 |
+
|
| 17 |
+
// Dynamic import of the protein viewer to prevent SSR issues
|
| 18 |
+
const ProteinViewer = dynamic(
|
| 19 |
+
() =>
|
| 20 |
+
import('./_components/ProteinViewer').then((mod) => mod.ProteinViewer),
|
| 21 |
+
{
|
| 22 |
+
ssr: false,
|
| 23 |
+
loading: () => <Skeleton className="size-[500px]" />,
|
| 24 |
+
}
|
| 25 |
+
);
|
| 26 |
+
|
| 27 |
+
export default function Proteins3DPage() {
|
| 28 |
+
const [proteins, setProteins] = useState<Protein[]>([]);
|
| 29 |
+
const [selectedProtein, setSelectedProtein] = useState<Protein | null>(null);
|
| 30 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 31 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 32 |
+
const [error, setError] = useState<string | null>(null);
|
| 33 |
+
|
| 34 |
+
// Load proteins list
|
| 35 |
+
useEffect(() => {
|
| 36 |
+
const loadProteins = async () => {
|
| 37 |
+
try {
|
| 38 |
+
setIsLoading(true);
|
| 39 |
+
const data = await getProteins();
|
| 40 |
+
setProteins(data);
|
| 41 |
+
if (data.length > 0) {
|
| 42 |
+
setSelectedProtein(data[0]);
|
| 43 |
+
}
|
| 44 |
+
} catch (err) {
|
| 45 |
+
setError(
|
| 46 |
+
err instanceof Error ? err.message : 'Failed to load proteins'
|
| 47 |
+
);
|
| 48 |
+
} finally {
|
| 49 |
+
setIsLoading(false);
|
| 50 |
+
}
|
| 51 |
+
};
|
| 52 |
+
loadProteins();
|
| 53 |
+
}, []);
|
| 54 |
+
|
| 55 |
+
const filteredProteins = useMemo(() => {
|
| 56 |
+
if (!searchQuery.trim()) return proteins;
|
| 57 |
+
const query = searchQuery.toLowerCase();
|
| 58 |
+
return proteins.filter(
|
| 59 |
+
(p) =>
|
| 60 |
+
p.name.toLowerCase().includes(query) ||
|
| 61 |
+
p.pdbId.toLowerCase().includes(query) ||
|
| 62 |
+
p.description?.toLowerCase().includes(query)
|
| 63 |
+
);
|
| 64 |
+
}, [proteins, searchQuery]);
|
| 65 |
+
|
| 66 |
+
if (error) {
|
| 67 |
+
return (
|
| 68 |
+
<div className="flex h-full items-center justify-center">
|
| 69 |
+
<Card className="max-w-md">
|
| 70 |
+
<CardHeader>
|
| 71 |
+
<CardTitle className="text-destructive">Error</CardTitle>
|
| 72 |
+
<CardDescription>{error}</CardDescription>
|
| 73 |
+
</CardHeader>
|
| 74 |
+
</Card>
|
| 75 |
+
</div>
|
| 76 |
+
);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
<div className="flex h-full gap-6 p-6">
|
| 81 |
+
{/* Left Panel - Protein List */}
|
| 82 |
+
<Card className="w-80 shrink-0">
|
| 83 |
+
<CardHeader className="pb-3">
|
| 84 |
+
<CardTitle className="text-lg">Proteins</CardTitle>
|
| 85 |
+
<CardDescription>Select a protein to visualize</CardDescription>
|
| 86 |
+
</CardHeader>
|
| 87 |
+
<CardContent className="pb-3">
|
| 88 |
+
<div className="relative">
|
| 89 |
+
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
| 90 |
+
<Input
|
| 91 |
+
placeholder="Search proteins..."
|
| 92 |
+
className="pl-8"
|
| 93 |
+
value={searchQuery}
|
| 94 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 95 |
+
/>
|
| 96 |
+
</div>
|
| 97 |
+
</CardContent>
|
| 98 |
+
<Separator />
|
| 99 |
+
<ScrollArea className="h-[calc(100vh-280px)]">
|
| 100 |
+
<div className="p-2">
|
| 101 |
+
{isLoading ? (
|
| 102 |
+
<div className="space-y-2">
|
| 103 |
+
{[1, 2, 3].map((i) => (
|
| 104 |
+
<Skeleton key={i} className="h-16 w-full" />
|
| 105 |
+
))}
|
| 106 |
+
</div>
|
| 107 |
+
) : filteredProteins.length === 0 ? (
|
| 108 |
+
<p className="p-4 text-center text-sm text-muted-foreground">
|
| 109 |
+
No proteins found
|
| 110 |
+
</p>
|
| 111 |
+
) : (
|
| 112 |
+
<div className="space-y-1">
|
| 113 |
+
{filteredProteins.map((protein) => (
|
| 114 |
+
<button
|
| 115 |
+
key={protein.id}
|
| 116 |
+
onClick={() => setSelectedProtein(protein)}
|
| 117 |
+
className={`w-full rounded-lg p-3 text-left transition-colors hover:bg-accent ${
|
| 118 |
+
selectedProtein?.id === protein.id
|
| 119 |
+
? 'bg-accent'
|
| 120 |
+
: 'bg-transparent'
|
| 121 |
+
}`}
|
| 122 |
+
>
|
| 123 |
+
<div className="flex items-center gap-2">
|
| 124 |
+
<span className="font-medium">{protein.name}</span>
|
| 125 |
+
<Badge variant="secondary" className="text-xs">
|
| 126 |
+
{protein.pdbId}
|
| 127 |
+
</Badge>
|
| 128 |
+
</div>
|
| 129 |
+
{protein.description && (
|
| 130 |
+
<div className="mt-1 truncate text-xs text-muted-foreground">
|
| 131 |
+
{protein.description}
|
| 132 |
+
</div>
|
| 133 |
+
)}
|
| 134 |
+
</button>
|
| 135 |
+
))}
|
| 136 |
+
</div>
|
| 137 |
+
)}
|
| 138 |
+
</div>
|
| 139 |
+
</ScrollArea>
|
| 140 |
+
</Card>
|
| 141 |
+
|
| 142 |
+
{/* Right Panel - 3D Visualization */}
|
| 143 |
+
<div className="flex flex-1 flex-col gap-6">
|
| 144 |
+
{selectedProtein ? (
|
| 145 |
+
<>
|
| 146 |
+
{/* Protein Info Card */}
|
| 147 |
+
<Card>
|
| 148 |
+
<CardHeader className="pb-3">
|
| 149 |
+
<div className="flex items-start justify-between">
|
| 150 |
+
<div>
|
| 151 |
+
<CardTitle>{selectedProtein.name}</CardTitle>
|
| 152 |
+
<CardDescription>
|
| 153 |
+
{selectedProtein.description}
|
| 154 |
+
</CardDescription>
|
| 155 |
+
</div>
|
| 156 |
+
<div className="flex items-center gap-2">
|
| 157 |
+
<Badge variant="outline">PDB: {selectedProtein.pdbId}</Badge>
|
| 158 |
+
<Button
|
| 159 |
+
variant="outline"
|
| 160 |
+
size="sm"
|
| 161 |
+
asChild
|
| 162 |
+
>
|
| 163 |
+
<a
|
| 164 |
+
href={getProteinPdbUrl(selectedProtein.id)}
|
| 165 |
+
target="_blank"
|
| 166 |
+
rel="noopener noreferrer"
|
| 167 |
+
>
|
| 168 |
+
<ExternalLink className="mr-1 size-4" />
|
| 169 |
+
Open PDB
|
| 170 |
+
</a>
|
| 171 |
+
</Button>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
</CardHeader>
|
| 175 |
+
<CardContent>
|
| 176 |
+
<div className="flex gap-4 text-sm text-muted-foreground">
|
| 177 |
+
<a
|
| 178 |
+
href={`https://www.rcsb.org/structure/${selectedProtein.pdbId}`}
|
| 179 |
+
target="_blank"
|
| 180 |
+
rel="noopener noreferrer"
|
| 181 |
+
className="flex items-center gap-1 hover:text-foreground"
|
| 182 |
+
>
|
| 183 |
+
<ExternalLink className="size-3" />
|
| 184 |
+
View on RCSB PDB
|
| 185 |
+
</a>
|
| 186 |
+
<a
|
| 187 |
+
href={`https://www.uniprot.org/uniprotkb?query=${selectedProtein.name}`}
|
| 188 |
+
target="_blank"
|
| 189 |
+
rel="noopener noreferrer"
|
| 190 |
+
className="flex items-center gap-1 hover:text-foreground"
|
| 191 |
+
>
|
| 192 |
+
<ExternalLink className="size-3" />
|
| 193 |
+
Search UniProt
|
| 194 |
+
</a>
|
| 195 |
+
</div>
|
| 196 |
+
</CardContent>
|
| 197 |
+
</Card>
|
| 198 |
+
|
| 199 |
+
{/* 3D Structure Card */}
|
| 200 |
+
<Card className="flex-1">
|
| 201 |
+
<CardHeader>
|
| 202 |
+
<CardTitle className="text-lg">3D Structure</CardTitle>
|
| 203 |
+
<CardDescription>
|
| 204 |
+
Rotate: click + drag • Zoom: scroll • Pan: right-click + drag
|
| 205 |
+
</CardDescription>
|
| 206 |
+
</CardHeader>
|
| 207 |
+
<CardContent className="flex items-center justify-center">
|
| 208 |
+
<ProteinViewer
|
| 209 |
+
key={selectedProtein.pdbId}
|
| 210 |
+
pdbId={selectedProtein.pdbId}
|
| 211 |
+
width={600}
|
| 212 |
+
height={500}
|
| 213 |
+
/>
|
| 214 |
+
</CardContent>
|
| 215 |
+
</Card>
|
| 216 |
+
</>
|
| 217 |
+
) : (
|
| 218 |
+
<div className="flex flex-1 items-center justify-center">
|
| 219 |
+
<p className="text-muted-foreground">
|
| 220 |
+
Select a protein to view its 3D structure
|
| 221 |
+
</p>
|
| 222 |
+
</div>
|
| 223 |
+
)}
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
);
|
| 227 |
+
}
|
ui/app/visualization/page.tsx
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState } from 'react';
|
| 4 |
+
|
| 5 |
+
import { PageHeader } from '@/components/page-header';
|
| 6 |
+
import { Button } from '@/components/ui/button';
|
| 7 |
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 8 |
+
import { Input } from '@/components/ui/input';
|
| 9 |
+
import { Label } from '@/components/ui/label';
|
| 10 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
| 11 |
+
import { ProteinViewer } from '@/components/visualization/protein-viewer';
|
| 12 |
+
import { Smiles2DViewer } from '@/components/visualization/smiles-2d-viewer';
|
| 13 |
+
|
| 14 |
+
// Example molecules
|
| 15 |
+
const EXAMPLE_SMILES = [
|
| 16 |
+
{ name: 'Aspirin', smiles: 'CC(=O)OC1=CC=CC=C1C(=O)O' },
|
| 17 |
+
{ name: 'Caffeine', smiles: 'CN1C=NC2=C1C(=O)N(C(=O)N2C)C' },
|
| 18 |
+
{ name: 'Ibuprofen', smiles: 'CC(C)CC1=CC=C(C=C1)C(C)C(=O)O' },
|
| 19 |
+
{ name: 'Paracetamol', smiles: 'CC(=O)NC1=CC=C(C=C1)O' },
|
| 20 |
+
];
|
| 21 |
+
|
| 22 |
+
// Example proteins (PDB IDs)
|
| 23 |
+
const EXAMPLE_PROTEINS = [
|
| 24 |
+
{ name: 'Hemoglobin', pdbId: '1HHO' },
|
| 25 |
+
{ name: 'Insulin', pdbId: '1ZNI' },
|
| 26 |
+
{ name: 'Lysozyme', pdbId: '1LYZ' },
|
| 27 |
+
{ name: 'Green Fluorescent Protein', pdbId: '1EMA' },
|
| 28 |
+
];
|
| 29 |
+
|
| 30 |
+
export default function VisualizationPage() {
|
| 31 |
+
const [customSmiles, setCustomSmiles] = useState('');
|
| 32 |
+
const [customPdbId, setCustomPdbId] = useState('');
|
| 33 |
+
const [activeSmiles, setActiveSmiles] = useState(EXAMPLE_SMILES[0].smiles);
|
| 34 |
+
const [activePdbId, setActivePdbId] = useState(EXAMPLE_PROTEINS[0].pdbId);
|
| 35 |
+
|
| 36 |
+
return (
|
| 37 |
+
<div className="container mx-auto p-6 space-y-6">
|
| 38 |
+
<PageHeader
|
| 39 |
+
title="Molecular Visualization"
|
| 40 |
+
subtitle="View 2D molecule structures and 3D protein models"
|
| 41 |
+
breadcrumbs={[
|
| 42 |
+
{ label: 'Home', href: '/' },
|
| 43 |
+
{ label: 'Visualization' },
|
| 44 |
+
]}
|
| 45 |
+
/>
|
| 46 |
+
|
| 47 |
+
<Tabs defaultValue="molecules" className="space-y-6">
|
| 48 |
+
<TabsList>
|
| 49 |
+
<TabsTrigger value="molecules">2D Molecules</TabsTrigger>
|
| 50 |
+
<TabsTrigger value="proteins">3D Proteins</TabsTrigger>
|
| 51 |
+
</TabsList>
|
| 52 |
+
|
| 53 |
+
<TabsContent value="molecules" className="space-y-6">
|
| 54 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 55 |
+
{/* Controls */}
|
| 56 |
+
<Card>
|
| 57 |
+
<CardHeader>
|
| 58 |
+
<CardTitle>Molecule Selection</CardTitle>
|
| 59 |
+
</CardHeader>
|
| 60 |
+
<CardContent className="space-y-4">
|
| 61 |
+
<div className="space-y-2">
|
| 62 |
+
<Label>Example Molecules</Label>
|
| 63 |
+
<div className="flex flex-wrap gap-2">
|
| 64 |
+
{EXAMPLE_SMILES.map((mol) => (
|
| 65 |
+
<Button
|
| 66 |
+
key={mol.name}
|
| 67 |
+
variant={activeSmiles === mol.smiles ? 'default' : 'outline'}
|
| 68 |
+
size="sm"
|
| 69 |
+
onClick={() => setActiveSmiles(mol.smiles)}
|
| 70 |
+
>
|
| 71 |
+
{mol.name}
|
| 72 |
+
</Button>
|
| 73 |
+
))}
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<div className="space-y-2">
|
| 78 |
+
<Label htmlFor="custom-smiles">Custom SMILES</Label>
|
| 79 |
+
<div className="flex gap-2">
|
| 80 |
+
<Input
|
| 81 |
+
id="custom-smiles"
|
| 82 |
+
placeholder="Enter SMILES string..."
|
| 83 |
+
value={customSmiles}
|
| 84 |
+
onChange={(e) => setCustomSmiles(e.target.value)}
|
| 85 |
+
/>
|
| 86 |
+
<Button
|
| 87 |
+
onClick={() => customSmiles && setActiveSmiles(customSmiles)}
|
| 88 |
+
disabled={!customSmiles}
|
| 89 |
+
>
|
| 90 |
+
View
|
| 91 |
+
</Button>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
<div className="p-3 bg-muted rounded-lg">
|
| 96 |
+
<Label className="text-xs text-muted-foreground">Current SMILES</Label>
|
| 97 |
+
<code className="block text-sm font-mono mt-1 break-all">
|
| 98 |
+
{activeSmiles}
|
| 99 |
+
</code>
|
| 100 |
+
</div>
|
| 101 |
+
</CardContent>
|
| 102 |
+
</Card>
|
| 103 |
+
|
| 104 |
+
{/* Viewer */}
|
| 105 |
+
<Card>
|
| 106 |
+
<CardHeader>
|
| 107 |
+
<CardTitle>2D Structure</CardTitle>
|
| 108 |
+
</CardHeader>
|
| 109 |
+
<CardContent className="flex justify-center">
|
| 110 |
+
<Smiles2DViewer
|
| 111 |
+
smiles={activeSmiles}
|
| 112 |
+
width={400}
|
| 113 |
+
height={300}
|
| 114 |
+
/>
|
| 115 |
+
</CardContent>
|
| 116 |
+
</Card>
|
| 117 |
+
</div>
|
| 118 |
+
</TabsContent>
|
| 119 |
+
|
| 120 |
+
<TabsContent value="proteins" className="space-y-6">
|
| 121 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 122 |
+
{/* Controls */}
|
| 123 |
+
<Card>
|
| 124 |
+
<CardHeader>
|
| 125 |
+
<CardTitle>Protein Selection</CardTitle>
|
| 126 |
+
</CardHeader>
|
| 127 |
+
<CardContent className="space-y-4">
|
| 128 |
+
<div className="space-y-2">
|
| 129 |
+
<Label>Example Proteins</Label>
|
| 130 |
+
<div className="flex flex-wrap gap-2">
|
| 131 |
+
{EXAMPLE_PROTEINS.map((protein) => (
|
| 132 |
+
<Button
|
| 133 |
+
key={protein.pdbId}
|
| 134 |
+
variant={activePdbId === protein.pdbId ? 'default' : 'outline'}
|
| 135 |
+
size="sm"
|
| 136 |
+
onClick={() => setActivePdbId(protein.pdbId)}
|
| 137 |
+
>
|
| 138 |
+
{protein.name}
|
| 139 |
+
</Button>
|
| 140 |
+
))}
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
|
| 144 |
+
<div className="space-y-2">
|
| 145 |
+
<Label htmlFor="custom-pdb">Custom PDB ID</Label>
|
| 146 |
+
<div className="flex gap-2">
|
| 147 |
+
<Input
|
| 148 |
+
id="custom-pdb"
|
| 149 |
+
placeholder="Enter PDB ID (e.g., 1HHO)..."
|
| 150 |
+
value={customPdbId}
|
| 151 |
+
onChange={(e) => setCustomPdbId(e.target.value.toUpperCase())}
|
| 152 |
+
maxLength={4}
|
| 153 |
+
/>
|
| 154 |
+
<Button
|
| 155 |
+
onClick={() => customPdbId && setActivePdbId(customPdbId)}
|
| 156 |
+
disabled={!customPdbId || customPdbId.length !== 4}
|
| 157 |
+
>
|
| 158 |
+
View
|
| 159 |
+
</Button>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<div className="p-3 bg-muted rounded-lg">
|
| 164 |
+
<Label className="text-xs text-muted-foreground">Current PDB ID</Label>
|
| 165 |
+
<code className="block text-sm font-mono mt-1">
|
| 166 |
+
{activePdbId}
|
| 167 |
+
</code>
|
| 168 |
+
</div>
|
| 169 |
+
</CardContent>
|
| 170 |
+
</Card>
|
| 171 |
+
|
| 172 |
+
{/* Viewer */}
|
| 173 |
+
<Card>
|
| 174 |
+
<CardHeader>
|
| 175 |
+
<CardTitle>3D Structure</CardTitle>
|
| 176 |
+
</CardHeader>
|
| 177 |
+
<CardContent className="flex justify-center">
|
| 178 |
+
<ProteinViewer
|
| 179 |
+
pdbId={activePdbId}
|
| 180 |
+
width={500}
|
| 181 |
+
height={400}
|
| 182 |
+
/>
|
| 183 |
+
</CardContent>
|
| 184 |
+
</Card>
|
| 185 |
+
</div>
|
| 186 |
+
</TabsContent>
|
| 187 |
+
</Tabs>
|
| 188 |
+
</div>
|
| 189 |
+
);
|
| 190 |
+
}
|
ui/components/sidebar.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
| 9 |
ChevronsUpDown,
|
| 10 |
CreditCard,
|
| 11 |
Dna,
|
|
|
|
| 12 |
Home,
|
| 13 |
LogOut,
|
| 14 |
Microscope,
|
|
@@ -16,7 +17,6 @@ import {
|
|
| 16 |
Sparkles,
|
| 17 |
Terminal,
|
| 18 |
User,
|
| 19 |
-
Workflow,
|
| 20 |
} from 'lucide-react';
|
| 21 |
import Link from 'next/link';
|
| 22 |
import { usePathname } from 'next/navigation';
|
|
@@ -52,87 +52,92 @@ import {
|
|
| 52 |
CollapsibleTrigger,
|
| 53 |
} from '@/components/animate-ui/primitives/radix/collapsible';
|
| 54 |
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
|
|
| 55 |
import { useIsMobile } from '@/hooks/use-mobile';
|
| 56 |
|
| 57 |
const navMain = [
|
| 58 |
{
|
| 59 |
title: 'Home',
|
| 60 |
-
url: '/',
|
| 61 |
icon: Home,
|
| 62 |
isActive: true,
|
| 63 |
},
|
| 64 |
{
|
| 65 |
-
title: '
|
| 66 |
-
url: '/
|
| 67 |
-
icon:
|
| 68 |
items: [
|
| 69 |
{
|
| 70 |
-
title: '
|
| 71 |
-
url: '/
|
| 72 |
},
|
| 73 |
{
|
| 74 |
-
title: '
|
| 75 |
-
url: '/
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
},
|
| 77 |
],
|
| 78 |
},
|
| 79 |
{
|
| 80 |
title: 'Discovery',
|
| 81 |
-
url: '/discovery',
|
| 82 |
icon: Microscope,
|
| 83 |
items: [
|
| 84 |
{
|
| 85 |
title: 'Drug Discovery',
|
| 86 |
-
url: '/discovery',
|
| 87 |
},
|
| 88 |
{
|
| 89 |
title: 'Molecule Search',
|
| 90 |
-
url: '/discovery#search',
|
| 91 |
},
|
| 92 |
],
|
| 93 |
},
|
| 94 |
{
|
| 95 |
title: 'Explorer',
|
| 96 |
-
url: '/explorer',
|
| 97 |
icon: Dna,
|
| 98 |
items: [
|
| 99 |
{
|
| 100 |
title: 'Embeddings',
|
| 101 |
-
url: '/explorer',
|
| 102 |
},
|
| 103 |
{
|
| 104 |
title: 'Predictions',
|
| 105 |
-
url: '/explorer#predictions',
|
| 106 |
},
|
| 107 |
],
|
| 108 |
},
|
| 109 |
{
|
| 110 |
title: 'Data',
|
| 111 |
-
url: '/data',
|
| 112 |
icon: BarChart2,
|
| 113 |
items: [
|
| 114 |
{
|
| 115 |
title: 'Datasets',
|
| 116 |
-
url: '/data',
|
| 117 |
},
|
| 118 |
{
|
| 119 |
title: 'Analytics',
|
| 120 |
-
url: '/data#analytics',
|
| 121 |
},
|
| 122 |
],
|
| 123 |
},
|
| 124 |
{
|
| 125 |
title: 'Settings',
|
| 126 |
-
url: '/settings',
|
| 127 |
icon: Settings,
|
| 128 |
items: [
|
| 129 |
{
|
| 130 |
title: 'General',
|
| 131 |
-
url: '/settings',
|
| 132 |
},
|
| 133 |
{
|
| 134 |
title: 'Models',
|
| 135 |
-
url: '/settings#models',
|
| 136 |
},
|
| 137 |
],
|
| 138 |
},
|
|
@@ -155,7 +160,7 @@ export function AppSidebar() {
|
|
| 155 |
<SidebarMenu>
|
| 156 |
<SidebarMenuItem>
|
| 157 |
<SidebarMenuButton size="lg" asChild>
|
| 158 |
-
<Link href="/">
|
| 159 |
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
| 160 |
<Dna className="size-4" />
|
| 161 |
</div>
|
|
@@ -167,6 +172,10 @@ export function AppSidebar() {
|
|
| 167 |
</SidebarMenuButton>
|
| 168 |
</SidebarMenuItem>
|
| 169 |
</SidebarMenu>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
{/* App Header */}
|
| 171 |
</SidebarHeader>
|
| 172 |
|
|
@@ -177,7 +186,7 @@ export function AppSidebar() {
|
|
| 177 |
<SidebarMenu>
|
| 178 |
{navMain.map((item) => {
|
| 179 |
const isActive = pathname === item.url || pathname?.startsWith(item.url + '/');
|
| 180 |
-
|
| 181 |
if (!item.items || item.items.length === 0) {
|
| 182 |
return (
|
| 183 |
<SidebarMenuItem key={item.title}>
|
|
|
|
| 9 |
ChevronsUpDown,
|
| 10 |
CreditCard,
|
| 11 |
Dna,
|
| 12 |
+
FlaskConical,
|
| 13 |
Home,
|
| 14 |
LogOut,
|
| 15 |
Microscope,
|
|
|
|
| 17 |
Sparkles,
|
| 18 |
Terminal,
|
| 19 |
User,
|
|
|
|
| 20 |
} from 'lucide-react';
|
| 21 |
import Link from 'next/link';
|
| 22 |
import { usePathname } from 'next/navigation';
|
|
|
|
| 52 |
CollapsibleTrigger,
|
| 53 |
} from '@/components/animate-ui/primitives/radix/collapsible';
|
| 54 |
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
| 55 |
+
import { ThemeToggle } from '@/components/theme-toggle';
|
| 56 |
import { useIsMobile } from '@/hooks/use-mobile';
|
| 57 |
|
| 58 |
const navMain = [
|
| 59 |
{
|
| 60 |
title: 'Home',
|
| 61 |
+
url: '/dashboard',
|
| 62 |
icon: Home,
|
| 63 |
isActive: true,
|
| 64 |
},
|
| 65 |
{
|
| 66 |
+
title: 'Visualization',
|
| 67 |
+
url: '/dashboard/molecules-2d',
|
| 68 |
+
icon: FlaskConical,
|
| 69 |
items: [
|
| 70 |
{
|
| 71 |
+
title: 'Molecules 2D',
|
| 72 |
+
url: '/dashboard/molecules-2d',
|
| 73 |
},
|
| 74 |
{
|
| 75 |
+
title: 'Molecules 3D',
|
| 76 |
+
url: '/dashboard/molecules-3d',
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
title: 'Proteins 3D',
|
| 80 |
+
url: '/dashboard/proteins-3d',
|
| 81 |
},
|
| 82 |
],
|
| 83 |
},
|
| 84 |
{
|
| 85 |
title: 'Discovery',
|
| 86 |
+
url: '/dashboard/discovery',
|
| 87 |
icon: Microscope,
|
| 88 |
items: [
|
| 89 |
{
|
| 90 |
title: 'Drug Discovery',
|
| 91 |
+
url: '/dashboard/discovery',
|
| 92 |
},
|
| 93 |
{
|
| 94 |
title: 'Molecule Search',
|
| 95 |
+
url: '/dashboard/discovery#search',
|
| 96 |
},
|
| 97 |
],
|
| 98 |
},
|
| 99 |
{
|
| 100 |
title: 'Explorer',
|
| 101 |
+
url: '/dashboard/explorer',
|
| 102 |
icon: Dna,
|
| 103 |
items: [
|
| 104 |
{
|
| 105 |
title: 'Embeddings',
|
| 106 |
+
url: '/dashboard/explorer',
|
| 107 |
},
|
| 108 |
{
|
| 109 |
title: 'Predictions',
|
| 110 |
+
url: '/dashboard/explorer#predictions',
|
| 111 |
},
|
| 112 |
],
|
| 113 |
},
|
| 114 |
{
|
| 115 |
title: 'Data',
|
| 116 |
+
url: '/dashboard/data',
|
| 117 |
icon: BarChart2,
|
| 118 |
items: [
|
| 119 |
{
|
| 120 |
title: 'Datasets',
|
| 121 |
+
url: '/dashboard/data',
|
| 122 |
},
|
| 123 |
{
|
| 124 |
title: 'Analytics',
|
| 125 |
+
url: '/dashboard/data#analytics',
|
| 126 |
},
|
| 127 |
],
|
| 128 |
},
|
| 129 |
{
|
| 130 |
title: 'Settings',
|
| 131 |
+
url: '/dashboard/settings',
|
| 132 |
icon: Settings,
|
| 133 |
items: [
|
| 134 |
{
|
| 135 |
title: 'General',
|
| 136 |
+
url: '/dashboard/settings',
|
| 137 |
},
|
| 138 |
{
|
| 139 |
title: 'Models',
|
| 140 |
+
url: '/dashboard/settings#models',
|
| 141 |
},
|
| 142 |
],
|
| 143 |
},
|
|
|
|
| 160 |
<SidebarMenu>
|
| 161 |
<SidebarMenuItem>
|
| 162 |
<SidebarMenuButton size="lg" asChild>
|
| 163 |
+
<Link href="/dashboard">
|
| 164 |
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
| 165 |
<Dna className="size-4" />
|
| 166 |
</div>
|
|
|
|
| 172 |
</SidebarMenuButton>
|
| 173 |
</SidebarMenuItem>
|
| 174 |
</SidebarMenu>
|
| 175 |
+
{/* Theme Toggle */}
|
| 176 |
+
<div className="group-data-[collapsible=icon]:hidden px-2">
|
| 177 |
+
<ThemeToggle />
|
| 178 |
+
</div>
|
| 179 |
{/* App Header */}
|
| 180 |
</SidebarHeader>
|
| 181 |
|
|
|
|
| 186 |
<SidebarMenu>
|
| 187 |
{navMain.map((item) => {
|
| 188 |
const isActive = pathname === item.url || pathname?.startsWith(item.url + '/');
|
| 189 |
+
|
| 190 |
if (!item.items || item.items.length === 0) {
|
| 191 |
return (
|
| 192 |
<SidebarMenuItem key={item.title}>
|
ui/components/theme-toggle.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Moon, Sun, Laptop } from "lucide-react"
|
| 5 |
+
import { useTheme } from "next-themes"
|
| 6 |
+
|
| 7 |
+
import { Button } from "@/components/ui/button"
|
| 8 |
+
import {
|
| 9 |
+
DropdownMenu,
|
| 10 |
+
DropdownMenuContent,
|
| 11 |
+
DropdownMenuItem,
|
| 12 |
+
DropdownMenuTrigger,
|
| 13 |
+
} from "@/components/animate-ui/components/radix/dropdown-menu"
|
| 14 |
+
|
| 15 |
+
export function ThemeToggle() {
|
| 16 |
+
const { setTheme, theme } = useTheme()
|
| 17 |
+
|
| 18 |
+
return (
|
| 19 |
+
<DropdownMenu>
|
| 20 |
+
<DropdownMenuTrigger asChild>
|
| 21 |
+
<Button variant="ghost" size="icon" className="h-9 w-9">
|
| 22 |
+
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
| 23 |
+
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
| 24 |
+
<span className="sr-only">Toggle theme</span>
|
| 25 |
+
</Button>
|
| 26 |
+
</DropdownMenuTrigger>
|
| 27 |
+
<DropdownMenuContent align="end">
|
| 28 |
+
<DropdownMenuItem onClick={() => setTheme("light")}>
|
| 29 |
+
<Sun className="mr-2 h-4 w-4" />
|
| 30 |
+
<span>Light</span>
|
| 31 |
+
</DropdownMenuItem>
|
| 32 |
+
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
| 33 |
+
<Moon className="mr-2 h-4 w-4" />
|
| 34 |
+
<span>Dark</span>
|
| 35 |
+
</DropdownMenuItem>
|
| 36 |
+
<DropdownMenuItem onClick={() => setTheme("system")}>
|
| 37 |
+
<Laptop className="mr-2 h-4 w-4" />
|
| 38 |
+
<span>System</span>
|
| 39 |
+
</DropdownMenuItem>
|
| 40 |
+
</DropdownMenuContent>
|
| 41 |
+
</DropdownMenu>
|
| 42 |
+
)
|
| 43 |
+
}
|
ui/components/ui/alert.tsx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 3 |
+
|
| 4 |
+
import { cn } from "@/lib/utils"
|
| 5 |
+
|
| 6 |
+
const alertVariants = cva(
|
| 7 |
+
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
| 8 |
+
{
|
| 9 |
+
variants: {
|
| 10 |
+
variant: {
|
| 11 |
+
default: "bg-card text-card-foreground",
|
| 12 |
+
destructive:
|
| 13 |
+
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
| 14 |
+
},
|
| 15 |
+
},
|
| 16 |
+
defaultVariants: {
|
| 17 |
+
variant: "default",
|
| 18 |
+
},
|
| 19 |
+
}
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
function Alert({
|
| 23 |
+
className,
|
| 24 |
+
variant,
|
| 25 |
+
...props
|
| 26 |
+
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
| 27 |
+
return (
|
| 28 |
+
<div
|
| 29 |
+
data-slot="alert"
|
| 30 |
+
role="alert"
|
| 31 |
+
className={cn(alertVariants({ variant }), className)}
|
| 32 |
+
{...props}
|
| 33 |
+
/>
|
| 34 |
+
)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
| 38 |
+
return (
|
| 39 |
+
<div
|
| 40 |
+
data-slot="alert-title"
|
| 41 |
+
className={cn(
|
| 42 |
+
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
| 43 |
+
className
|
| 44 |
+
)}
|
| 45 |
+
{...props}
|
| 46 |
+
/>
|
| 47 |
+
)
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function AlertDescription({
|
| 51 |
+
className,
|
| 52 |
+
...props
|
| 53 |
+
}: React.ComponentProps<"div">) {
|
| 54 |
+
return (
|
| 55 |
+
<div
|
| 56 |
+
data-slot="alert-description"
|
| 57 |
+
className={cn(
|
| 58 |
+
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
| 59 |
+
className
|
| 60 |
+
)}
|
| 61 |
+
{...props}
|
| 62 |
+
/>
|
| 63 |
+
)
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export { Alert, AlertTitle, AlertDescription }
|
ui/components/ui/scroll-area.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
function ScrollArea({
|
| 9 |
+
className,
|
| 10 |
+
children,
|
| 11 |
+
...props
|
| 12 |
+
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
| 13 |
+
return (
|
| 14 |
+
<ScrollAreaPrimitive.Root
|
| 15 |
+
data-slot="scroll-area"
|
| 16 |
+
className={cn("relative", className)}
|
| 17 |
+
{...props}
|
| 18 |
+
>
|
| 19 |
+
<ScrollAreaPrimitive.Viewport
|
| 20 |
+
data-slot="scroll-area-viewport"
|
| 21 |
+
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
| 22 |
+
>
|
| 23 |
+
{children}
|
| 24 |
+
</ScrollAreaPrimitive.Viewport>
|
| 25 |
+
<ScrollBar />
|
| 26 |
+
<ScrollAreaPrimitive.Corner />
|
| 27 |
+
</ScrollAreaPrimitive.Root>
|
| 28 |
+
)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function ScrollBar({
|
| 32 |
+
className,
|
| 33 |
+
orientation = "vertical",
|
| 34 |
+
...props
|
| 35 |
+
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
| 36 |
+
return (
|
| 37 |
+
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
| 38 |
+
data-slot="scroll-area-scrollbar"
|
| 39 |
+
orientation={orientation}
|
| 40 |
+
className={cn(
|
| 41 |
+
"flex touch-none p-px transition-colors select-none",
|
| 42 |
+
orientation === "vertical" &&
|
| 43 |
+
"h-full w-2.5 border-l border-l-transparent",
|
| 44 |
+
orientation === "horizontal" &&
|
| 45 |
+
"h-2.5 flex-col border-t border-t-transparent",
|
| 46 |
+
className
|
| 47 |
+
)}
|
| 48 |
+
{...props}
|
| 49 |
+
>
|
| 50 |
+
<ScrollAreaPrimitive.ScrollAreaThumb
|
| 51 |
+
data-slot="scroll-area-thumb"
|
| 52 |
+
className="bg-border relative flex-1 rounded-full"
|
| 53 |
+
/>
|
| 54 |
+
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
| 55 |
+
)
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export { ScrollArea, ScrollBar }
|
ui/lib/visualization-api.ts
CHANGED
|
@@ -52,6 +52,7 @@ export function getMoleculeSdfUrl(id: string): string {
|
|
| 52 |
return `${API_BASE}/molecules/${id}/sdf`;
|
| 53 |
}
|
| 54 |
|
| 55 |
-
export function getProteinPdbUrl(
|
| 56 |
-
|
|
|
|
| 57 |
}
|
|
|
|
| 52 |
return `${API_BASE}/molecules/${id}/sdf`;
|
| 53 |
}
|
| 54 |
|
| 55 |
+
export function getProteinPdbUrl(pdbId: string): string {
|
| 56 |
+
// Fetch directly from RCSB PDB for now
|
| 57 |
+
return `https://files.rcsb.org/download/${pdbId.toUpperCase()}.pdb`;
|
| 58 |
}
|
ui/package.json
CHANGED
|
@@ -16,6 +16,7 @@
|
|
| 16 |
"@radix-ui/react-collapsible": "^1.1.12",
|
| 17 |
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
| 18 |
"@radix-ui/react-label": "^2.1.8",
|
|
|
|
| 19 |
"@radix-ui/react-select": "^2.2.6",
|
| 20 |
"@radix-ui/react-separator": "^1.1.8",
|
| 21 |
"@radix-ui/react-slider": "^1.3.6",
|
|
@@ -28,6 +29,7 @@
|
|
| 28 |
"lucide-react": "^0.563.0",
|
| 29 |
"motion": "^12.29.2",
|
| 30 |
"next": "^16.1.4",
|
|
|
|
| 31 |
"radix-ui": "^1.4.3",
|
| 32 |
"react": "^19.2.3",
|
| 33 |
"react-dom": "^19.2.3",
|
|
|
|
| 16 |
"@radix-ui/react-collapsible": "^1.1.12",
|
| 17 |
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
| 18 |
"@radix-ui/react-label": "^2.1.8",
|
| 19 |
+
"@radix-ui/react-scroll-area": "^1.2.10",
|
| 20 |
"@radix-ui/react-select": "^2.2.6",
|
| 21 |
"@radix-ui/react-separator": "^1.1.8",
|
| 22 |
"@radix-ui/react-slider": "^1.3.6",
|
|
|
|
| 29 |
"lucide-react": "^0.563.0",
|
| 30 |
"motion": "^12.29.2",
|
| 31 |
"next": "^16.1.4",
|
| 32 |
+
"next-themes": "^0.4.6",
|
| 33 |
"radix-ui": "^1.4.3",
|
| 34 |
"react": "^19.2.3",
|
| 35 |
"react-dom": "^19.2.3",
|
ui/pnpm-lock.yaml
CHANGED
|
@@ -26,6 +26,9 @@ importers:
|
|
| 26 |
'@radix-ui/react-label':
|
| 27 |
specifier: ^2.1.8
|
| 28 |
version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
|
|
|
|
|
|
|
|
|
| 29 |
'@radix-ui/react-select':
|
| 30 |
specifier: ^2.2.6
|
| 31 |
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
|
@@ -62,6 +65,9 @@ importers:
|
|
| 62 |
next:
|
| 63 |
specifier: ^16.1.4
|
| 64 |
version: 16.1.4(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
|
|
|
|
|
|
|
|
|
| 65 |
radix-ui:
|
| 66 |
specifier: ^1.4.3
|
| 67 |
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
|
@@ -3367,6 +3373,12 @@ packages:
|
|
| 3367 |
netcdfjs@3.0.0:
|
| 3368 |
resolution: {integrity: sha512-LOvT8KkC308qtpUkcBPiCMBtii7ZQCN6LxcVheWgyUeZ6DQWcpSRFV9dcVXLj/2eHZ/bre9tV5HTH4Sf93vrFw==}
|
| 3369 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3370 |
next@16.1.4:
|
| 3371 |
resolution: {integrity: sha512-gKSecROqisnV7Buen5BfjmXAm7Xlpx9o2ueVQRo5DxQcjC8d330dOM1xiGWc2k3Dcnz0In3VybyRPOsudwgiqQ==}
|
| 3372 |
engines: {node: '>=20.9.0'}
|
|
@@ -7625,6 +7637,11 @@ snapshots:
|
|
| 7625 |
dependencies:
|
| 7626 |
iobuffer: 5.4.0
|
| 7627 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7628 |
next@16.1.4(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
| 7629 |
dependencies:
|
| 7630 |
'@next/env': 16.1.4
|
|
|
|
| 26 |
'@radix-ui/react-label':
|
| 27 |
specifier: ^2.1.8
|
| 28 |
version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
| 29 |
+
'@radix-ui/react-scroll-area':
|
| 30 |
+
specifier: ^1.2.10
|
| 31 |
+
version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
| 32 |
'@radix-ui/react-select':
|
| 33 |
specifier: ^2.2.6
|
| 34 |
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
|
|
|
| 65 |
next:
|
| 66 |
specifier: ^16.1.4
|
| 67 |
version: 16.1.4(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
| 68 |
+
next-themes:
|
| 69 |
+
specifier: ^0.4.6
|
| 70 |
+
version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
| 71 |
radix-ui:
|
| 72 |
specifier: ^1.4.3
|
| 73 |
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
|
|
|
| 3373 |
netcdfjs@3.0.0:
|
| 3374 |
resolution: {integrity: sha512-LOvT8KkC308qtpUkcBPiCMBtii7ZQCN6LxcVheWgyUeZ6DQWcpSRFV9dcVXLj/2eHZ/bre9tV5HTH4Sf93vrFw==}
|
| 3375 |
|
| 3376 |
+
next-themes@0.4.6:
|
| 3377 |
+
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
|
| 3378 |
+
peerDependencies:
|
| 3379 |
+
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
| 3380 |
+
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
| 3381 |
+
|
| 3382 |
next@16.1.4:
|
| 3383 |
resolution: {integrity: sha512-gKSecROqisnV7Buen5BfjmXAm7Xlpx9o2ueVQRo5DxQcjC8d330dOM1xiGWc2k3Dcnz0In3VybyRPOsudwgiqQ==}
|
| 3384 |
engines: {node: '>=20.9.0'}
|
|
|
|
| 7637 |
dependencies:
|
| 7638 |
iobuffer: 5.4.0
|
| 7639 |
|
| 7640 |
+
next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
| 7641 |
+
dependencies:
|
| 7642 |
+
react: 19.2.3
|
| 7643 |
+
react-dom: 19.2.3(react@19.2.3)
|
| 7644 |
+
|
| 7645 |
next@16.1.4(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
| 7646 |
dependencies:
|
| 7647 |
'@next/env': 16.1.4
|
ui/types/smiles-drawer.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
declare module 'smiles-drawer' {
|
| 2 |
+
interface SmiDrawerOptions {
|
| 3 |
+
width?: number;
|
| 4 |
+
height?: number;
|
| 5 |
+
bondThickness?: number;
|
| 6 |
+
bondLength?: number;
|
| 7 |
+
shortBondLength?: number;
|
| 8 |
+
bondSpacing?: number;
|
| 9 |
+
atomVisualization?: 'default' | 'balls' | 'none';
|
| 10 |
+
isomeric?: boolean;
|
| 11 |
+
debug?: boolean;
|
| 12 |
+
terminalCarbons?: boolean;
|
| 13 |
+
explicitHydrogens?: boolean;
|
| 14 |
+
compactDrawing?: boolean;
|
| 15 |
+
fontSizeLarge?: number;
|
| 16 |
+
fontSizeSmall?: number;
|
| 17 |
+
padding?: number;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
class SmiDrawer {
|
| 21 |
+
constructor(options?: SmiDrawerOptions);
|
| 22 |
+
draw(
|
| 23 |
+
smiles: string,
|
| 24 |
+
target: string | HTMLCanvasElement,
|
| 25 |
+
theme?: 'light' | 'dark',
|
| 26 |
+
onSuccess?: () => void,
|
| 27 |
+
onError?: (error: Error) => void
|
| 28 |
+
): void;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export { SmiDrawer };
|
| 32 |
+
export default { SmiDrawer };
|
| 33 |
+
}
|