'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import { DownloadSimple as Download } from '@phosphor-icons/react'; import { HudPanel, HudLegend, LegendItem } from '@/components/ui'; import { downloadText } from '@/lib/export-utils'; import { StructureExportMenu } from '@/components/StructureExportMenu'; import { useTheme } from '@/contexts/theme'; interface MoleViewer { setStyle: (sel: Record, style: Record) => void; removeAllSurfaces: () => void; addSurface: (type: unknown, opts: Record) => void; render: () => void; clear: () => void; addModel: (data: string, format: string) => void; zoomTo: () => void; spin: (axis: string | boolean, speed?: number) => void; resize: () => void; removeAllLabels: () => void; addLabel: (text: string, opts: Record) => void; } interface MoleAtom { b: number; elem?: string; x?: number; y?: number; z?: number; serial?: number; resn?: string; resi?: number; chain?: string; [key: string]: unknown; } declare global { interface Window { $3Dmol: Record & { createViewer: (el: HTMLElement, opts: Record) => MoleViewer; SurfaceType: Record }; } } const SCRIPT_URL = 'https://3Dmol.org/build/3Dmol-min.js'; const CONFIDENCE_BANDS = [ { label: 'Very high (pLDDT > 90)', color: '#0053D6' }, { label: 'Confident (70–90)', color: '#65CBF3' }, { label: 'Low (50–70)', color: '#FFDB13' }, { label: 'Very low (< 50)', color: '#FF7D45' }, ] as const; function plddtColor(b: number): string { if (b > 90) return CONFIDENCE_BANDS[0].color; if (b > 70) return CONFIDENCE_BANDS[1].color; if (b > 50) return CONFIDENCE_BANDS[2].color; return CONFIDENCE_BANDS[3].color; } let scriptPromise: Promise | null = null; function load3Dmol(): Promise { if (typeof window === 'undefined') { return Promise.reject(new Error('3Dmol can only run in the browser')); } if (window.$3Dmol) return Promise.resolve(); if (scriptPromise) return scriptPromise; scriptPromise = new Promise((resolve, reject) => { const existing = document.querySelector(`script[src="${SCRIPT_URL}"]`); if (existing) { existing.addEventListener('load', () => resolve()); existing.addEventListener('error', () => reject(new Error('Failed to load 3Dmol.js'))); return; } const script = document.createElement('script'); script.src = SCRIPT_URL; script.async = true; script.onload = () => resolve(); script.onerror = () => reject(new Error('Failed to load 3Dmol.js')); document.head.appendChild(script); }); return scriptPromise; } type StyleMode = 'confidence' | 'spectrum' | 'surface' | 'stick'; interface AlphaFoldViewerProps { pdbUrl?: string | null; pdbData?: string | null; uniprotId?: string; height?: number | string; backgroundColor?: string; } export function AlphaFoldViewer({ pdbUrl, pdbData, uniprotId, height = 420, backgroundColor, }: AlphaFoldViewerProps) { const { theme } = useTheme(); const containerRef = useRef(null); const viewerRef = useRef(null); const pdbTextRef = useRef(null); const sceneBg = backgroundColor ?? (theme === 'light' ? '#FFFFFF' : '#0B0C14'); const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading'); const [error, setError] = useState(null); const [styleMode, setStyleMode] = useState('confidence'); const [spinning, setSpinning] = useState(false); const exportPdb = () => { if (!pdbTextRef.current) return; downloadText(pdbTextRef.current, `${uniprotId ?? 'structure'}.pdb`); }; const exportPng = () => { const canvas = containerRef.current?.querySelector('canvas'); if (!canvas) return; const a = document.createElement('a'); a.download = `${uniprotId ?? 'structure'}.png`; a.href = canvas.toDataURL('image/png'); a.click(); }; const applyStyle = useCallback((mode: StyleMode) => { const viewer = viewerRef.current; const $3Dmol = window.$3Dmol; if (!viewer || !$3Dmol) return; viewer.removeAllSurfaces(); viewer.setStyle({}, {}); switch (mode) { case 'confidence': viewer.setStyle({}, { cartoon: { colorfunc: (atom: MoleAtom) => plddtColor(atom.b) } }); break; case 'spectrum': viewer.setStyle({}, { cartoon: { color: 'spectrum' } }); break; case 'surface': viewer.setStyle({}, { cartoon: { colorfunc: (atom: MoleAtom) => plddtColor(atom.b) } }); viewer.addSurface($3Dmol.SurfaceType.VDW, { opacity: 0.85, colorfunc: (atom: MoleAtom) => plddtColor(atom.b), }); break; case 'stick': viewer.setStyle({}, { stick: { colorfunc: (atom: MoleAtom) => plddtColor(atom.b) } }); break; } viewer.render(); }, []); useEffect(() => { if (!pdbUrl && !pdbData) { setStatus('error'); setError('No structure data provided yet'); return; } let cancelled = false; setStatus('loading'); setError(null); const loadText: Promise = pdbData ? load3Dmol().then(() => pdbData) : load3Dmol() .then(() => fetch(pdbUrl as string)) .then((res) => { if (!res.ok) throw new Error(`Failed to fetch structure (HTTP ${res.status})`); return res.text(); }); loadText .then((pdbData) => { if (cancelled || !containerRef.current) return; pdbTextRef.current = pdbData; const $3Dmol = window.$3Dmol; if (viewerRef.current) { viewerRef.current.clear(); } else { viewerRef.current = $3Dmol.createViewer(containerRef.current, { backgroundColor: sceneBg }); } viewerRef.current.addModel(pdbData, 'pdb'); applyStyle(styleMode); viewerRef.current.zoomTo(); viewerRef.current.render(); setStatus('ready'); }) .catch((err: Error) => { if (cancelled) return; setError( err.message === 'Failed to fetch' ? 'Could not fetch the structure file (likely a CORS issue — see notes below)' : err.message ); setStatus('error'); }); return () => { cancelled = true; }; }, [pdbUrl, pdbData, sceneBg, applyStyle, styleMode]); useEffect(() => { if (status === 'ready') applyStyle(styleMode); }, [styleMode, status, applyStyle]); useEffect(() => { const viewer = viewerRef.current; if (!viewer) return; if (spinning) viewer.spin('y', 1); else viewer.spin(false); }, [spinning, status]); useEffect(() => { const viewer = viewerRef.current; const container = containerRef.current; if (!viewer || !container) return; const observer = new ResizeObserver(() => viewer.resize()); observer.observe(container); return () => observer.disconnect(); }, [status]); return (
{/* HUD toolbar — floats in the viewer's space, near-opaque */} {uniprotId ? `AlphaFold model — ${uniprotId}` : pdbData ? 'Predicted structure (ESMFold)' : 'AlphaFold model'}
{uniprotId && }
{status === 'loading' && (
Loading structure...
)} {status === 'error' && (
{error}
)} {/* Corner-anchored, near-opaque legend — never blurred over the model */} {status === 'ready' && styleMode !== 'spectrum' && ( {CONFIDENCE_BANDS.map((band) => ( ))} )}
); }