MuSProt / frontend /src /components /StructureViewer.tsx
WinslowFan's picture
Claude Opus 5.5
Redesign the MuSProt website for the new database release
dd079af
Raw History Blame Contribute Delete
4.41 kB
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<string, unknown>) => Promise<void> | void;
visual: { select: (params: Record<string, unknown>) => Promise<void> | void };
events: { loadComplete: { subscribe: (cb: (ok: boolean) => void) => { unsubscribe?: () => void } } };
plugin?: { dispose?: () => void };
}
declare global {
interface Window {
PDBeMolstarPlugin?: new () => MolstarInstance;
}
}
let loader: Promise<void> | null = null;
function loadMolstar(): Promise<void> {
if (window.PDBeMolstarPlugin) return Promise.resolve();
if (!loader) {
loader = new Promise<void>((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<HTMLDivElement>(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 (
<figure className="viewer" style={{ height }}>
<div ref={ref} className="viewer__canvas" />
{status !== 'ready' && (
<div className="viewer__overlay">
{status === 'loading' ? (
<>
<span className="spinner" aria-hidden /> Loading {pdbId.toUpperCase()}…
</>
) : (
<>
<Box aria-hidden /> 3D view unavailable for {pdbId.toUpperCase()}
</>
)}
</div>
)}
{label && <figcaption className="viewer__label">{label}</figcaption>}
</figure>
);
}