import { useEffect, useRef, useState } from 'react'; import { Box } from 'lucide-react'; /* PDBe Mol* is ~6 MB, so it is loaded from the CDN only when a viewer is opened. */ const MOLSTAR_VERSION = '3.12.0'; const BASE = `https://cdn.jsdelivr.net/npm/pdbe-molstar@${MOLSTAR_VERSION}/build`; interface MolstarInstance { render: (el: HTMLElement, options: Record) => Promise | void; visual: { select: (params: Record) => Promise | void }; events: { loadComplete: { subscribe: (cb: (ok: boolean) => void) => { unsubscribe?: () => void } } }; plugin?: { dispose?: () => void }; } declare global { interface Window { PDBeMolstarPlugin?: new () => MolstarInstance; } } let loader: Promise | null = null; function loadMolstar(): Promise { if (window.PDBeMolstarPlugin) return Promise.resolve(); if (!loader) { loader = new Promise((resolve, reject) => { const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = `${BASE}/pdbe-molstar-light.css`; document.head.appendChild(css); const s = document.createElement('script'); s.src = `${BASE}/pdbe-molstar-plugin.js`; s.async = true; s.onload = () => resolve(); s.onerror = () => { loader = null; reject(new Error('Could not load the Mol* viewer')); }; document.head.appendChild(s); }); } return loader; } function hexToRgb(hex: string) { const n = parseInt(hex.replace('#', ''), 16); return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; } function isDark() { const t = document.documentElement.dataset.theme; if (t) return t === 'dark'; return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false; } export function StructureViewer({ pdbId, chain, color = '#2a78d6', height = 380, label, }: { pdbId: string; chain: string; color?: string; height?: number; label?: string; }) { const ref = useRef(null); const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading'); useEffect(() => { let alive = true; let sub: { unsubscribe?: () => void } | undefined; let instance: MolstarInstance | undefined; const el = ref.current; setStatus('loading'); // Mol* reports nothing when WebGL is unavailable; don't spin forever const timeout = setTimeout(() => alive && setStatus((st) => (st === 'loading' ? 'error' : st)), 90000); loadMolstar() .then(() => { if (!alive || !el || !window.PDBeMolstarPlugin) return; instance = new window.PDBeMolstarPlugin(); sub = instance.events.loadComplete.subscribe((ok) => { if (!alive) return; if (ok === false) { setStatus('error'); return; } setStatus('ready'); instance?.visual.select({ data: [{ auth_asym_id: chain, color: hexToRgb(color), focus: true }], nonSelectedColor: isDark() ? { r: 70, g: 78, b: 92 } : { r: 214, g: 219, b: 226 }, }); }); const bg = isDark() ? { r: 18, g: 24, b: 33 } : { r: 255, g: 255, b: 255 }; instance.render(el, { moleculeId: pdbId.toLowerCase(), bgColor: bg, hideControls: true, sequencePanel: false, landscape: true, visualStyle: 'cartoon', hideCanvasControls: ['expand', 'selection', 'animation', 'controlToggle', 'controlInfo'], loadingOverlay: true, }); }) .catch(() => alive && setStatus('error')); return () => { alive = false; clearTimeout(timeout); sub?.unsubscribe?.(); instance?.plugin?.dispose?.(); if (el) el.innerHTML = ''; }; }, [pdbId, chain, color]); return (
{status !== 'ready' && (
{status === 'loading' ? ( <> Loading {pdbId.toUpperCase()}… ) : ( <> 3D view unavailable for {pdbId.toUpperCase()} )}
)} {label &&
{label}
}
); }