Mol* viewer: aaredb's structure-viewer setup, on a black stage

The owner's verdict on the screenshots: aaredb (gitea.psi.ch/mx/aaredb, the PSI MX group's own
structure viewer) has it right, so its Mol* setup is taken over, files copied from
frontend/src/components/Structure at 8ece2c2 into frontend/src/components/structure:
- createPlugin.ts: headless DefaultPluginSpec plugin, manual camera reset; background black (owner)
  instead of aaredb's 0x0d1a26.
- MolstarCanvas.tsx: fresh canvas per mount, one-tick StrictMode deferral, resize, WebGL context-loss
  recovery, the Coot control profile wired.
- cootControls.ts / cootTrackball.ts: Coot mouse language - left drag rotates (Coot 0.9.8 trackball),
  right drag zooms, middle drag pans, middle click recentres on a residue, scroll clips the slab,
  shift+scroll zooms (camera sync and residue pick left out: no split view here).
- loadModel.ts: cartoon coloured by chain from a palette clear of the map colours, polymer sticks and
  waters built hidden, ligands as ball-and-stick; display modes cartoon / sticks / both and a waters
  switch; reads mmCIF too, and gives the atom coordinates for carving.
- loadMaps.ts: isosurface wireframe meshes at ABSOLUTE levels from the full cell's mean and RMS,
  2Fo-Fc steel blue, Fo-Fc green/red at +/-sigma, not pickable; plus the anomalous map as a magenta
  +sigma mesh in the same style.
- densityCrop.ts: the density worker's crop to the model's box + 6 A, the 4.5 A carve of the 2Fo-Fc map
  around the atoms (never a difference map) and the CCP4 encoding, applied to the broker's one-cell map.
The panel and defaults follow aaredb's StructureView: map rows with switch and sigma slider (2Fo-Fc 1.0,
0.5-4 by 0.1; Fo-Fc 3.0, 1-8 by 0.5; anomalous 4.0, 1-8 by 0.5), debounced sigma updates, model mode
buttons, a controls legend. This replaces the camera-following 10 A map sphere: aaredb's equivalent is
the carved map with the scroll-driven clip slab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 5763b619fe4ec9999ac790960a5eb033f511c546)
This commit is contained in:
leonarski_f committed 2026-10-22 20:18:51 +02:00
1 parent 543d7548c7
commit 2df1d8a2ad
8 files changed
+1059 -294

No files matched your search

+175 -294
View File
@@ -1,35 +1,54 @@
import {useEffect, useRef, useState} from "react";
import {Alert, Box, Checkbox, FormControlLabel, Slider, Stack, Typography} from "@mui/material";
import {PluginContext} from "molstar/lib/mol-plugin/context";
import {DefaultPluginSpec} from "molstar/lib/mol-plugin/spec";
import {StateTransforms} from "molstar/lib/mol-plugin-state/transforms";
import {createVolumeRepresentationParams} from "molstar/lib/mol-plugin-state/helpers/volume-representation-params";
import {setSubtreeVisibility} from "molstar/lib/mol-plugin/behavior/static/state";
import {Volume} from "molstar/lib/mol-model/volume";
import {Color} from "molstar/lib/mol-util/color";
import {Mat4, Vec3} from "molstar/lib/mol-math/linear-algebra";
import {Sphere3D} from "molstar/lib/mol-math/geometry";
import React, {useCallback, useEffect, useRef, useState} from "react";
import {Alert, Box, Slider, Switch, Typography} from "@mui/material";
import type {PluginContext} from "molstar/lib/mol-plugin/context";
import {authHeaders, noteResponse} from "../auth";
import MolstarCanvas from "./structure/MolstarCanvas";
import {applyModelDisplay, clearScene, getStructureAtoms, getStructureBox, LoadedModel, loadModel, ReprMode}
from "./structure/loadModel";
import {loadMapLayer, MAP_COLORS, MapLayer, setLayerVisible, updateLayerSigma} from "./structure/loadMaps";
import {fullCellFromCcp4, prepareMap} from "./structure/densityCrop";
// The model rugnux live placed, with its 2mFo-DFc, mFo-DFc and (where there are Bijvoet pairs) anomalous
// difference maps, in a Mol* viewer (headless plugin: the
// render core and state tree, no Mol* UI). Mol* is only loaded with this component (React.lazy in
// RugnuxPage). The broker serves the maps with the whole cell's mean and
// RMS in their headers, so the contour levels in sigma are the usual crystallographic ones.
//
// The broker serves one unit cell of each map, centred on the model (more where the model is wider than the
// cell). As in Coot, the view opens with the maps drawn only within 10 A of the centre of view, which follows
// the camera (a click on an atom centres it); radius 0 draws the whole cell.
// The model rugnux live placed, with its 2mFo-DFc, mFo-DFc and (where the merge kept the Bijvoet pairs) anomalous
// difference maps, in Mol*. Set up as aaredb's structure viewer (gitea.psi.ch/mx/aaredb, pages/StructureView.tsx
// and components/Structure/, the PSI MX group's own code): its plugin, Coot mouse controls, model presets, map
// meshes, crop and carve, panel and defaults - on a black stage. Mol* is only loaded with this component
// (React.lazy in RugnuxPage), and every Mol* import lives in ./structure.
const COLOR_2FOFC = 0x3b6fd6;
const COLOR_FOFC_POS = 0x2ca02c;
const COLOR_FOFC_NEG = 0xd62728;
const COLOR_ANOM = 0xd61ad6;
type Kind = 'twofofc' | 'fofc' | 'anom';
interface MapLayer {
volume: string;
reprs: string[]; // one for 2mFo-DFc, +/- for mFo-DFc
}
const hex = (c: number) => '#' + c.toString(16).padStart(6, '0');
const SectionTitle: React.FC<{ children: React.ReactNode }> = ({children}) => (
<Typography sx={{fontSize: '0.6786rem', letterSpacing: '.05em', textTransform: 'uppercase',
color: 'text.secondary', mt: 2, mb: 1}}>{children}</Typography>
);
// one map row: colored dot · name · switch · σ slider + readout (aaredb StructureView.tsx:47-77)
const MapRow: React.FC<{
label: string;
dotColor: string;
on: boolean;
onToggle: (v: boolean) => void;
sigma: number;
min: number;
max: number;
step: number;
onSigma: (v: number) => void;
disabled?: boolean;
}> = ({label, dotColor, on, onToggle, sigma, min, max, step, onSigma, disabled}) => (
<Box sx={{mb: 1}}>
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
<Box sx={{width: 10, height: 10, borderRadius: '3px', bgcolor: dotColor, flex: 'none'}}/>
<Typography sx={{fontSize: '0.8125rem', flex: 1}}>{label}</Typography>
<Typography sx={{fontSize: '0.8125rem', color: 'text.secondary', width: 44, textAlign: 'right'}}>
{sigma.toFixed(1)} σ</Typography>
<Switch size="small" checked={on} disabled={disabled} onChange={(e) => onToggle(e.target.checked)}/>
</Box>
<Slider size="small" value={sigma} min={min} max={max} step={step} disabled={disabled || !on}
onChange={(_, v) => onSigma(v as number)} aria-label={`${label} contour level`}
sx={{mx: 0.5, width: 'calc(100% - 8px)', color: dotColor, '& .MuiSlider-thumb': {width: 12, height: 12}}}/>
</Box>
);
async function fetchBytes(path: string, signal: AbortSignal): Promise<ArrayBuffer | undefined> {
const res = await fetch(path, {headers: authHeaders(), signal});
@@ -41,142 +60,34 @@ async function fetchBytes(path: string, signal: AbortSignal): Promise<ArrayBuffe
return res.arrayBuffer();
}
// What is drawn of a map: the inside of a sphere of `radius` A around `centre`; radius 0 draws all of it.
function sphereClip(centre: Vec3, radius: number) {
return {
variant: 'pixel' as const,
objects: radius > 0 ? [{
type: 'sphere' as const,
invert: true, // keep the inside
position: Vec3.clone(centre),
rotation: {axis: Vec3.create(1, 0, 0), angle: 0},
scale: Vec3.create(2 * radius, 2 * radius, 2 * radius), // the diameter
transform: Mat4.identity(),
}] : [],
};
}
async function addIsosurface(plugin: PluginContext, volume: string, sigma: number, color: number,
clip: ReturnType<typeof sphereClip>) {
const cell = plugin.state.data.select(volume)[0];
const repr = plugin.build().to(volume).apply(StateTransforms.Representation.VolumeRepresentation3D,
createVolumeRepresentationParams(plugin, cell?.obj?.data, {
type: 'isosurface',
typeParams: {isoValue: Volume.IsoValue.relative(sigma), visuals: ['wireframe'], clip},
color: 'uniform',
colorParams: {value: Color(color)},
}));
await repr.commit();
return repr.ref;
}
async function loadMap(plugin: PluginContext, bytes: ArrayBuffer, label: string, sigmas: number[],
colors: number[], clip: ReturnType<typeof sphereClip>): Promise<MapLayer> {
const data = await plugin.builders.data.rawData({data: new Uint8Array(bytes), label});
const parsed = await plugin.dataFormats.get('ccp4')!.parse(plugin, data);
const volume: string = parsed.volume.ref;
const reprs: string[] = [];
for (let i = 0; i < sigmas.length; i++)
reprs.push(await addIsosurface(plugin, volume, sigmas[i], colors[i], clip));
return {volume, reprs};
}
async function setSigma(plugin: PluginContext, layer: MapLayer | undefined, sigmas: number[]) {
if (!layer)
return;
const b = plugin.build();
layer.reprs.forEach((ref, i) =>
b.to(ref).update((old: { type: { params: { isoValue: Volume.IsoValue } } }) => {
old.type.params.isoValue = Volume.IsoValue.relative(sigmas[i]);
}));
await b.commit();
}
async function setClip(plugin: PluginContext, layers: (MapLayer | undefined)[], clip: ReturnType<typeof sphereClip>) {
const b = plugin.build();
for (const layer of layers)
layer?.reprs.forEach(ref =>
b.to(ref).update((old: { type: { params: { clip: unknown } } }) => {
old.type.params.clip = clip;
}));
await b.commit();
}
function setVisible(plugin: PluginContext, layer: MapLayer | undefined, visible: boolean) {
if (layer)
setSubtreeVisibility(plugin.state.data, layer.volume, !visible); // the flag is "hidden"
}
// resultKey: changes when a new result is there to load; anomalous: it has an anomalous difference map.
function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anomalous: boolean }) {
const containerRef = useRef<HTMLDivElement>(null);
const [plugin, setPlugin] = useState<PluginContext>();
const pluginRef = useRef<PluginContext | null>(null);
const [pluginReady, setPluginReady] = useState(0);
const [error, setError] = useState<string>();
const [loading, setLoading] = useState(false);
const maps = useRef<{ twofofc?: MapLayer, fofc?: MapLayer, anom?: MapLayer }>({});
const allMaps = () => [maps.current.twofofc, maps.current.fofc, maps.current.anom];
const [hasFofc, setHasFofc] = useState(false);
const [sigma2, setSigma2] = useState(1.0);
const [sigma1, setSigma1] = useState(3.0);
const [show2, setShow2] = useState(true);
const [show1, setShow1] = useState(true);
const [hasAnom, setHasAnom] = useState(false);
const [sigmaA, setSigmaA] = useState(4.0);
const [showA, setShowA] = useState(true);
const [radius, setRadius] = useState(10);
// The levels as they are when a map is loaded, without making the loading depend on them.
const levels = useRef({sigma2, sigma1, sigmaA, show2, show1, showA, radius});
levels.current = {sigma2, sigma1, sigmaA, show2, show1, showA, radius};
const centre = useRef(Vec3.zero());
const layersRef = useRef<Partial<Record<Kind, MapLayer>>>({});
const modelRefsRef = useRef<LoadedModel['refs']>({});
const sigmaTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [hasMap, setHasMap] = useState<Record<Kind, boolean>>({twofofc: false, fofc: false, anom: false});
// The plugin, once per mount. A canvas of its own every time: dispose() loses the WebGL context of the
// canvas it ran on, so a second plugin on the same one (React StrictMode mounts twice) would start dead.
useEffect(() => {
const container = containerRef.current;
if (!container)
return;
let disposed = false;
let instance: PluginContext | undefined;
const canvas = document.createElement('canvas');
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.display = 'block';
container.appendChild(canvas);
(async () => {
try {
const p = new PluginContext(DefaultPluginSpec());
await p.init();
if (!await p.initViewerAsync(canvas, container)) {
p.dispose();
throw new Error("WebGL is not available");
}
if (disposed) {
p.dispose();
return;
}
// The camera is moved by this component alone: by default Mol* resets it whenever the scene's
// bounds change, i.e. as each map arrives, which undoes the zoom on the centre of view.
p.canvas3d?.setProps({renderer: {backgroundColor: Color(0xffffff)}, camera: {manualReset: true}});
instance = p;
setPlugin(p);
} catch (e) {
if (!disposed)
setError(e instanceof Error ? e.message : String(e));
}
})();
const resize = new ResizeObserver(() => instance?.handleResize());
resize.observe(container);
return () => {
disposed = true;
resize.disconnect();
instance?.dispose();
canvas.remove();
setPlugin(undefined);
};
// aaredb factory defaults (state/viewerDefaults.ts:16-23); anomalous as a difference map, +4 sigma
const [show, setShow] = useState<Record<Kind, boolean>>({twofofc: true, fofc: true, anom: true});
const [sigma, setSigma] = useState<Record<Kind, number>>({twofofc: 1.0, fofc: 3.0, anom: 4.0});
const [reprMode, setReprMode] = useState<ReprMode>('cartoon');
const [showWaters, setShowWaters] = useState(false);
const displayRef = useRef({show, sigma, reprMode, showWaters});
displayRef.current = {show, sigma, reprMode, showWaters};
const onPluginReady = useCallback((plugin: PluginContext) => {
pluginRef.current = plugin;
setPluginReady(n => n + 1);
}, []);
// The model and its maps, again for every new result.
// The model, then its maps (aaredb StructureView.tsx maybeLoad / computeAndAttachMaps, 212-295): 2Fo-Fc cropped
// to the model's box + 6 A and carved 4.5 A around the atoms; the difference maps cropped only.
useEffect(() => {
const plugin = pluginRef.current;
if (!plugin)
return;
const abort = new AbortController();
@@ -184,10 +95,9 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom
setLoading(true);
setError(undefined);
try {
await plugin.clear();
maps.current = {};
setHasFofc(false);
setHasAnom(false);
await clearScene(plugin);
layersRef.current = {};
setHasMap({twofofc: false, fofc: false, anom: false});
const [cif, map2, map1, mapA] = await Promise.all([
fetchBytes("/rugnux/model.cif", abort.signal),
fetchBytes("/rugnux/map.ccp4?type=2fofc", abort.signal),
@@ -199,49 +109,26 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom
setError("No placed model");
return;
}
const data = await plugin.builders.data.rawData({data: new TextDecoder().decode(cif), label: "model"});
const trajectory = await plugin.builders.structure.parseTrajectory(data, 'mmcif');
const model = await plugin.builders.structure.createModel(trajectory);
const structure = await plugin.builders.structure.createStructure(model);
const polymer = await plugin.builders.structure.tryCreateComponentStatic(structure, 'polymer');
if (polymer)
await plugin.builders.structure.representation.addRepresentation(polymer,
{type: 'cartoon', color: 'chain-id'});
for (const kind of ['ligand', 'ion'] as const) {
const c = await plugin.builders.structure.tryCreateComponentStatic(structure, kind);
if (c)
await plugin.builders.structure.representation.addRepresentation(c,
{type: 'ball-and-stick', color: 'element-symbol'});
const loaded = await loadModel(plugin, cif, 'model', {resetCamera: true, format: 'mmcif'});
modelRefsRef.current = loaded.refs;
const d = displayRef.current;
applyModelDisplay(plugin, loaded.refs, d.reprMode, d.showWaters);
const box = getStructureBox(loaded.structure);
const crop = box ? {...box, marginA: 6} : null;
const atoms = getStructureAtoms(loaded.structure);
const maps: [Kind, '2fofc' | 'fofc' | 'anom', ArrayBuffer | undefined][] =
[['twofofc', '2fofc', map2], ['fofc', 'fofc', map1], ['anom', 'anom', mapA]];
for (const [kind, name, bytes] of maps) {
if (!bytes || abort.signal.aborted)
continue;
const computed = prepareMap(name, fullCellFromCcp4(bytes), crop,
kind === 'twofofc' && atoms.length ? atoms : null);
const layer = await loadMapLayer(plugin, computed, name, d.sigma[kind]);
layersRef.current[kind] = layer;
if (!displayRef.current.show[kind])
setLayerVisible(plugin, layer, false);
setHasMap(h => ({...h, [kind]: true}));
}
// The model's centre, which a map radius is first drawn around.
plugin.managers.camera.reset(undefined, 0);
const target = plugin.canvas3d?.camera.state.target;
if (target)
Vec3.copy(centre.current, target);
const l = levels.current;
const clip = sphereClip(centre.current, l.radius);
if (map2) {
maps.current.twofofc = await loadMap(plugin, map2, "2mFo-DFc", [l.sigma2], [COLOR_2FOFC], clip);
setVisible(plugin, maps.current.twofofc, l.show2);
}
if (map1) {
maps.current.fofc = await loadMap(plugin, map1, "mFo-DFc", [l.sigma1, -l.sigma1],
[COLOR_FOFC_POS, COLOR_FOFC_NEG], clip);
setVisible(plugin, maps.current.fofc, l.show1);
setHasFofc(true);
}
if (mapA) {
maps.current.anom = await loadMap(plugin, mapA, "anomalous", [l.sigmaA], [COLOR_ANOM], clip);
setVisible(plugin, maps.current.anom, l.showA);
setHasAnom(true);
}
// The view opens on all of it - the cell of map the broker serves and the model - or, with a map
// radius set, on the sphere of map around the model's centre.
if (l.radius > 0)
plugin.managers.camera.focusSphere(Sphere3D.create(Vec3.clone(centre.current), l.radius + 4),
{durationMs: 0});
else
plugin.managers.camera.reset(undefined, 0);
} catch (e) {
if (!abort.signal.aborted)
setError(e instanceof Error ? e.message : String(e));
@@ -251,103 +138,97 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom
}
})();
return () => abort.abort();
}, [plugin, resultKey, anomalous]);
}, [pluginReady, resultKey, anomalous]);
// The maps follow the centre of view - it moves on a pan or a click on an atom, not on a rotation or zoom.
useEffect(() => {
const camera = plugin?.canvas3d?.camera;
if (!plugin || !camera)
return;
let timer: ReturnType<typeof setTimeout> | undefined;
const sub = camera.stateChanged.subscribe(() => {
if (Vec3.distance(camera.state.target, centre.current) < 0.5 || timer !== undefined)
return;
timer = setTimeout(() => {
timer = undefined;
Vec3.copy(centre.current, camera.state.target);
setClip(plugin, allMaps(),
sphereClip(centre.current, levels.current.radius)).catch(() => undefined);
}, 150);
});
return () => {
sub.unsubscribe();
if (timer !== undefined)
clearTimeout(timer);
};
}, [plugin]);
useEffect(() => {
if (plugin)
setClip(plugin, allMaps(), sphereClip(centre.current, radius))
.catch(() => undefined);
}, [plugin, radius]);
useEffect(() => {
if (plugin)
setSigma(plugin, maps.current.twofofc, [sigma2]).catch(() => undefined);
}, [plugin, sigma2]);
useEffect(() => {
if (plugin)
setSigma(plugin, maps.current.fofc, [sigma1, -sigma1]).catch(() => undefined);
}, [plugin, sigma1]);
useEffect(() => {
if (plugin)
setVisible(plugin, maps.current.twofofc, show2);
}, [plugin, show2]);
useEffect(() => {
if (plugin)
setVisible(plugin, maps.current.fofc, show1);
}, [plugin, show1]);
useEffect(() => {
if (plugin)
setSigma(plugin, maps.current.anom, [sigmaA]).catch(() => undefined);
}, [plugin, sigmaA]);
useEffect(() => {
if (plugin)
setVisible(plugin, maps.current.anom, showA);
}, [plugin, showA]);
// σ sliders: debounced param-only updates (re-mesh, no recompute) - aaredb StructureView.tsx:321-330
const applySigma = useCallback((kind: Kind, value: number) => {
setSigma(s => ({...s, [kind]: value}));
if (sigmaTimer.current)
clearTimeout(sigmaTimer.current);
sigmaTimer.current = setTimeout(() => {
const plugin = pluginRef.current;
const layer = layersRef.current[kind];
if (plugin && layer)
void updateLayerSigma(plugin, layer, value);
}, 90);
}, []);
const toggleLayer = useCallback((kind: Kind, visible: boolean) => {
setShow(s => ({...s, [kind]: visible}));
const plugin = pluginRef.current;
const layer = layersRef.current[kind];
if (plugin && layer)
setLayerVisible(plugin, layer, visible);
}, []);
const setDisplay = useCallback((mode: ReprMode, waters: boolean) => {
setReprMode(mode);
setShowWaters(waters);
const plugin = pluginRef.current;
if (plugin)
applyModelDisplay(plugin, modelRefsRef.current, mode, waters);
}, []);
const mapsDisabled = loading;
return (
<Stack spacing={1}>
<Box sx={{position: "relative", height: {xs: 360, md: 520}, borderRadius: 1, overflow: "hidden",
border: "1px solid", borderColor: "divider"}}>
<Box ref={containerRef} sx={{position: "absolute", inset: 0}}/>
{loading && <Typography sx={{position: "absolute", top: 8, left: 12, color: "text.secondary"}}>
<Box sx={{display: 'grid', gridTemplateColumns: {xs: '1fr', md: '1fr 300px'}, border: '1px solid',
borderColor: 'divider', borderRadius: 1, overflow: 'hidden'}}>
<Box sx={{position: 'relative', bgcolor: '#000', height: {xs: '55vh', md: 560}}}>
<MolstarCanvas onReady={onPluginReady}/>
{loading && <Typography sx={{position: 'absolute', top: 8, left: 12, color: '#8aa0b5'}}>
Loading model and maps...</Typography>}
</Box>
{error && <Alert severity="warning">{error}</Alert>}
<Stack direction={{xs: "column", md: "row"}} spacing={3} alignItems={{md: "center"}}>
<Stack direction="row" spacing={2} alignItems="center" sx={{flex: 1}}>
<FormControlLabel control={<Checkbox checked={show2} onChange={e => setShow2(e.target.checked)}/>}
label={<Typography sx={{color: "#3b6fd6", fontWeight: 700, whiteSpace: "nowrap"}}>
2mFo-DFc {sigma2.toFixed(1)} σ</Typography>}/>
<Slider value={sigma2} min={0.3} max={4} step={0.1} size="small"
onChange={(_, v) => setSigma2(v as number)} aria-label="2mFo-DFc contour level"/>
</Stack>
{hasFofc && <Stack direction="row" spacing={2} alignItems="center" sx={{flex: 1}}>
<FormControlLabel control={<Checkbox checked={show1} onChange={e => setShow1(e.target.checked)}/>}
label={<Typography sx={{color: "#2ca02c", fontWeight: 700, whiteSpace: "nowrap"}}>
mFo-DFc ±{sigma1.toFixed(1)} σ</Typography>}/>
<Slider value={sigma1} min={1.5} max={6} step={0.1} size="small"
onChange={(_, v) => setSigma1(v as number)} aria-label="mFo-DFc contour level"/>
</Stack>}
{hasAnom && <Stack direction="row" spacing={2} alignItems="center" sx={{flex: 1}}>
<FormControlLabel control={<Checkbox checked={showA} onChange={e => setShowA(e.target.checked)}/>}
label={<Typography sx={{color: "#d61ad6", fontWeight: 700, whiteSpace: "nowrap"}}>
anomalous {sigmaA.toFixed(1)} σ</Typography>}/>
<Slider value={sigmaA} min={2} max={10} step={0.1} size="small"
onChange={(_, v) => setSigmaA(v as number)} aria-label="Anomalous contour level"/>
</Stack>}
</Stack>
<Stack direction="row" spacing={2} alignItems="center" sx={{maxWidth: 520}}>
<Typography sx={{whiteSpace: "nowrap"}}>
Maps within {radius > 0 ? `${radius} Å of the centre` : "the whole cell"}</Typography>
<Slider value={radius} min={0} max={30} step={1} size="small"
onChange={(_, v) => setRadius(v as number)} aria-label="Map radius"/>
</Stack>
<Typography variant="body2" color="text.secondary">
Left drag rotates, right drag pans, wheel zooms, a click on an atom centres it. The maps cover one unit
cell around the model; with a radius set they are drawn only that far around the centre of view.
</Typography>
</Stack>
<Box sx={{borderLeft: {md: '1px solid'}, borderColor: {md: 'divider'}, p: 1.75}}>
{error && <Alert severity="warning" sx={{mb: 1}}>{error}</Alert>}
<SectionTitle>Model</SectionTitle>
<Box sx={{display: 'flex', gap: 0.75, flexWrap: 'wrap', alignItems: 'center', mb: 0.75}}>
{(['cartoon', 'sticks', 'both'] as const).map((m) => (
<Typography key={m} role="button" tabIndex={0}
onClick={() => setDisplay(m, showWaters)}
onKeyDown={(e) => { if (e.key === 'Enter') setDisplay(m, showWaters); }}
sx={{fontSize: '0.8125rem', px: 1, py: '3px', borderRadius: '8px',
border: '1px solid', cursor: 'pointer',
color: reprMode === m ? 'primary.main' : 'text.secondary',
borderColor: reprMode === m ? 'primary.main' : 'divider'}}
>{m === 'both' ? 'cartoon + sticks' : m}</Typography>
))}
</Box>
<Box sx={{display: 'flex', alignItems: 'center', justifyContent: 'space-between'}}>
<Typography sx={{fontSize: '0.8125rem'}}>Waters</Typography>
<Switch size="small" checked={showWaters} onChange={(e) => setDisplay(reprMode, e.target.checked)}/>
</Box>
<SectionTitle>Maps</SectionTitle>
{hasMap.twofofc && <MapRow label="2mFo−DFc" dotColor={hex(MAP_COLORS.twofofc)} on={show.twofofc}
onToggle={(v) => toggleLayer('twofofc', v)} sigma={sigma.twofofc}
min={0.5} max={4} step={0.1} onSigma={(v) => applySigma('twofofc', v)}
disabled={mapsDisabled}/>}
{hasMap.fofc && <MapRow label="mFo−DFc (±)" dotColor={hex(MAP_COLORS.fofcPos)} on={show.fofc}
onToggle={(v) => toggleLayer('fofc', v)} sigma={sigma.fofc}
min={1} max={8} step={0.5} onSigma={(v) => applySigma('fofc', v)}
disabled={mapsDisabled}/>}
{hasMap.anom && <MapRow label="Anomalous (+)" dotColor={hex(MAP_COLORS.anom)} on={show.anom}
onToggle={(v) => toggleLayer('anom', v)} sigma={sigma.anom}
min={1} max={8} step={0.5} onSigma={(v) => applySigma('anom', v)}
disabled={mapsDisabled}/>}
{!loading && !hasMap.twofofc && <Typography sx={{fontSize: '0.8125rem', color: 'text.secondary'}}>
No maps</Typography>}
<SectionTitle>Controls</SectionTitle>
{([
['left drag', 'rotate'],
['right drag', 'zoom'],
['middle drag', 'pan'],
['middle click', 'center on residue'],
['scroll', 'clip'],
] as const).map(([k, v]) => (
<Box key={k} sx={{display: 'flex', justifyContent: 'space-between', py: '1px'}}>
<Typography component="span" sx={{fontSize: '0.75rem', color: 'text.secondary'}}>{k}</Typography>
<Typography component="span" sx={{fontSize: '0.75rem'}}>{v}</Typography>
</Box>
))}
</Box>
</Box>
);
}
@@ -0,0 +1,107 @@
// Adapted from aaredb (gitea.psi.ch/mx/aaredb, the PSI MX group's own code), frontend/src/components/Structure/MolstarCanvas.tsx at 8ece2c2.
import React, { useEffect, useRef, useState } from 'react';
import { Box, Typography } from '@mui/material';
import type { PluginContext } from 'molstar/lib/mol-plugin/context';
import { createStructurePlugin } from './createPlugin';
import { applyCootBindings, wireCootRotate, wireMiddleClickRecenter, wireScrollClip, wireModifierDragRestart } from './cootControls';
// A Mol* WebGL surface: owns the plugin lifecycle (init → onReady → dispose on
// unmount), resize handling and context-loss recovery. At most TWO mount at a
// time (the viewer + its side-by-side compare pane — WebGL contexts are a
// limited browser resource); cards and lists show <img> snapshots only.
const MolstarCanvas: React.FC<{
onReady: (plugin: PluginContext) => void;
}> = ({ onReady }) => {
const containerRef = useRef<HTMLDivElement>(null);
const pluginRef = useRef<PluginContext | null>(null);
const [contextLost, setContextLost] = useState(false);
const [initError, setInitError] = useState<string | null>(null);
useEffect(() => {
let disposed = false;
const container = containerRef.current;
if (!container) return undefined;
// A FRESH canvas per effect run: Mol*'s dispose() deliberately loses the
// WebGL context, so under StrictMode's mount→unmount→mount the second
// init on a reused <canvas> would start dead ("context lost").
const canvas = document.createElement('canvas');
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.display = 'block';
container.appendChild(canvas);
let unwireControls: (() => void) | null = null;
(async () => {
try {
// One-tick deferral: StrictMode's throwaway first pass unmounts
// synchronously, so this skips its WebGL init entirely (and with
// it the ResizeObserver noise from aborting Mol* mid-attach).
await new Promise((r) => setTimeout(r, 0));
if (disposed) return;
const plugin = await createStructurePlugin(canvas, container);
if (disposed) {
plugin.dispose();
return;
}
pluginRef.current = plugin;
// coot control profile: left rotate · right zoom · middle pan ·
// middle-click recenter on residue · scroll clip
applyCootBindings(plugin);
const unRecenter = wireMiddleClickRecenter(plugin);
const unClip = wireScrollClip(plugin);
const unRestart = wireModifierDragRestart(canvas);
const unRotate = wireCootRotate(plugin);
unwireControls = () => { unRecenter(); unClip(); unRestart(); unRotate(); };
if (import.meta.env.DEV) {
(window as unknown as Record<string, unknown>).__structurePlugin = plugin;
}
onReady(plugin);
} catch (e) {
if (!disposed) setInitError(e instanceof Error ? e.message : 'viewer init failed');
}
})();
const onLost = (e: Event) => {
e.preventDefault();
if (!disposed) setContextLost(true);
};
canvas.addEventListener('webglcontextlost', onLost);
const ro = new ResizeObserver(() => pluginRef.current?.handleResize());
ro.observe(container);
return () => {
disposed = true;
ro.disconnect();
canvas.removeEventListener('webglcontextlost', onLost);
unwireControls?.();
pluginRef.current?.dispose();
pluginRef.current = null;
canvas.remove();
setContextLost(false);
setInitError(null);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Box ref={containerRef} sx={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
{(contextLost || initError) && (
<Box sx={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'rgba(0,0,0,.88)' }}>
<Typography
role={contextLost ? 'button' : undefined}
tabIndex={contextLost ? 0 : undefined}
onClick={contextLost ? () => window.location.reload() : undefined}
sx={{ fontSize: '0.875rem', color: '#dfe7ef', cursor: contextLost ? 'pointer' : 'default' }}
>
{contextLost ? 'Graphics context lost — click to reload the viewer' : `Viewer failed to start: ${initError}`}
</Typography>
</Box>
)}
</Box>
);
};
export default MolstarCanvas;
@@ -0,0 +1,198 @@
// Adapted from aaredb (gitea.psi.ch/mx/aaredb, the PSI MX group's own code), frontend/src/components/Structure/molstar/cootControls.ts (camera sync and residue pick left out) at 8ece2c2.
import { PluginContext } from 'molstar/lib/mol-plugin/context';
import { DefaultTrackballBindings } from 'molstar/lib/mol-canvas3d/controls/trackball';
import { Binding } from 'molstar/lib/mol-util/binding';
import { ButtonsType, ModifiersKeys } from 'molstar/lib/mol-util/input/input-observer';
import { StructureElement } from 'molstar/lib/mol-model/structure';
import { Loci } from 'molstar/lib/mol-model/loci';
import { Vec3, Quat } from 'molstar/lib/mol-math/linear-algebra';
import { cootTrackball, toTrackballCoords } from './cootTrackball';
// The "coot" control profile — the mouse language crystallographers already
// know from Coot:
// left drag rotate — Coot 0.9.8's virtual trackball (wireCootRotate)
// right drag zoom in/out
// middle drag pan (translate)
// middle CLICK recenter on the clicked residue (distance/orientation kept)
// scroll clip (slab through the density around the center)
// shift+scroll zoom (kept as an escape hatch)
// Future settings can add other profiles; this is the default.
const B = ButtonsType;
const M = ModifiersKeys;
const T = Binding.Trigger;
export const COOT_TRACKBALL_BINDINGS = {
...DefaultTrackballBindings, // keep key/gamepad bindings intact
// plain left drag is rotate, but NOT via Mol*'s trackball (linear, ~2× faster
// than Coot and never rolls) — wireCootRotate below runs Coot 0.9.8's instead.
dragRotate: Binding.Empty,
dragRotateZ: Binding(
[T(B.Flag.Primary, M.create({ shift: true, control: true }))],
'Rotate around z-axis (roll)', 'Drag using ${triggers}',
),
dragPan: Binding(
[T(B.Flag.Auxilary, M.create()), T(B.Flag.Primary, M.create({ control: true }))],
'Pan', 'Drag using ${triggers}',
),
dragZoom: Binding([T(B.Flag.Secondary, M.create())], 'Zoom', 'Drag using ${triggers}'),
dragFocus: Binding.Empty,
dragFocusZoom: Binding.Empty,
scrollZoom: Binding([T(B.Flag.Auxilary, M.create({ shift: true }))], 'Zoom', 'Scroll using ${triggers}'),
// plain scroll = clip, implemented in wireScrollClip below (the trackball's
// own scrollFocus accumulator proved unreliable; direct camera radius
// updates are deterministic) — keep it Empty here to avoid double handling.
scrollFocus: Binding.Empty,
scrollFocusZoom: Binding.Empty,
};
export function applyCootBindings(plugin: PluginContext): void {
// Bindings are TrackballControls ATTRIBS (construction-time config), not
// props — setProps silently ignores them; setAttribs is the live setter
// (Canvas3D.setAttribs → controls.setAttribs → Object.assign).
plugin.canvas3d?.setAttribs({ trackball: { bindings: COOT_TRACKBALL_BINDINGS } });
// Negative zoomSpeed flips the drag→zoom direction to match Coot's feel
// (Mol*'s default is the opposite); shift+scroll zoom flips with it, which
// keeps the whole zoom gesture family consistent. The PD min/max on this
// param are UI hints only — runtime accepts the negative value.
plugin.canvas3d?.setProps({ trackball: { zoomSpeed: -7 } });
}
/** Left-drag rotation with Coot 0.9.8's virtual trackball (cootTrackball.ts):
* spherical surface, incremental from the previous pointer position, x and y
* normalised by the canvas width and height like Coot's glarea. Mutates the
* camera the way Mol*'s own controls do (position/up in place + requestDraw),
* so camera.changed still fires and the split-view mirror keeps working.
* Returns an unsubscribe. */
export function wireCootRotate(plugin: PluginContext): () => void {
const canvas3d = plugin.canvas3d;
const input = canvas3d?.input;
if (!canvas3d || !input) return () => undefined;
const camera = canvas3d.camera;
let prev: [number, number] | null = null;
const eye = Vec3(), eyeDir = Vec3(), up = Vec3(), side = Vec3(), axis = Vec3();
const q = Quat();
const sub = input.drag.subscribe((e) => {
const m = e.modifiers;
if (e.buttons !== B.Flag.Primary || m.shift || m.control || m.alt || m.meta) { prev = null; return; }
// e.x/e.y are CSS px from the canvas top-left; input.width/height are device px
const pr = input.pixelRatio || 1;
const cur = toTrackballCoords(e.x, e.y, input.width / pr, input.height / pr);
if (e.isStart || !prev) { prev = cur; return; }
const rot = cootTrackball(prev[0], prev[1], cur[0], cur[1]);
prev = cur;
if (!rot) return;
// screen basis in world space: x right, y up, z toward the viewer
Vec3.sub(eye, camera.position, camera.target);
Vec3.normalize(eyeDir, eye);
Vec3.normalize(up, camera.up);
Vec3.normalize(side, Vec3.cross(side, up, eyeDir));
Vec3.scale(axis, side, rot.axis[0]);
Vec3.scaleAndAdd(axis, axis, up, rot.axis[1]);
Vec3.scaleAndAdd(axis, axis, eyeDir, rot.axis[2]);
// the scene turns by +angle about the axis, so the camera turns by the inverse
Quat.setAxisAngle(q, axis, -rot.angle);
Vec3.transformQuat(eye, eye, q);
Vec3.transformQuat(camera.up, camera.up, q);
Vec3.add(camera.position, camera.target, eye);
canvas3d.requestDraw();
});
const end = input.interactionEnd.subscribe(() => { prev = null; });
return () => { sub.unsubscribe(); end.unsubscribe(); };
}
/** Plain scroll adjusts the clip slab (Coot-style): shrinks/grows the visible
* radius around the camera target so the user can focus where the center is.
* Returns an unsubscribe. */
export function wireScrollClip(plugin: PluginContext): () => void {
const input = plugin.canvas3d?.input;
if (!input) return () => undefined;
const sub = input.wheel.subscribe(({ spinY, modifiers }) => {
// shift+scroll stays zoom (trackball binding)
if (modifiers.shift || modifiers.control || modifiers.alt || modifiers.meta) return;
const camera = plugin.canvas3d?.camera;
if (!camera || !spinY) return;
const r = camera.state.radius;
const max = camera.state.radiusMax || r;
const next = Math.min(max, Math.max(1.5, r * (1 - spinY * 0.08)));
if (next !== r) camera.setState({ radius: next });
});
return () => sub.unsubscribe();
}
/** Coot-style recenter: move the camera target to `center`, preserving
* orientation — and distance, unless `distanceA` asks for a specific one
* (blob deep-links arrive zoomed to the scene, not at overview distance).
* Shared by middle-click and the sequence strip. */
export function recenterCamera(plugin: PluginContext, center: Vec3 | number[], distanceA?: number): void {
const camera = plugin.canvas3d?.camera;
if (!camera) return;
const c = Vec3.create(center[0], center[1], center[2]);
const { target, position } = camera.state;
const dir = Vec3.sub(Vec3(), position, target);
if (distanceA) Vec3.setMagnitude(dir, dir, distanceA);
camera.setState(
{
target: c,
position: Vec3.add(Vec3(), c, dir),
// an explicit distance is "frame this spot": clip a coot-like slab
// around it too, so the point of interest isn't buried in density
...(distanceA ? { radius: distanceA * 0.45 } : {}),
},
250,
);
}
/** Mol*'s trackball re-matches drag bindings per event but initializes a
* gesture's anchor only on isStart — pressing/releasing a modifier MID-drag
* (e.g. Ctrl while rotating) switches rotate→pan against a stale anchor and
* the view jumps (measured 18 Å per 10 px). Restart the drag synthetically
* when a modifier toggles while a button is down, so the newly-matched
* gesture starts from the current pointer position. */
export function wireModifierDragRestart(el: HTMLElement): () => void {
let buttons = 0;
let last: { x: number; y: number } | null = null;
const down = (e: MouseEvent) => { buttons = e.buttons; last = { x: e.clientX, y: e.clientY }; };
const move = (e: MouseEvent) => { if (e.buttons) { buttons = e.buttons; last = { x: e.clientX, y: e.clientY }; } };
const up = () => { buttons = 0; last = null; };
const restart = (e: KeyboardEvent) => {
if (!buttons || !last) return;
if (!['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return;
const mods = { ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey };
const base = { bubbles: true, clientX: last.x, clientY: last.y, ...mods };
// primary button of the held combination — enough for our bindings.
// Mol*'s InputObserver listens to MOUSE events (not pointer events).
const button = buttons & 4 ? 1 : buttons & 2 ? 2 : 0;
el.dispatchEvent(new MouseEvent('mouseup', { ...base, button, buttons: 0 }));
el.dispatchEvent(new MouseEvent('mousedown', { ...base, button, buttons }));
};
el.addEventListener('mousedown', down);
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
window.addEventListener('keydown', restart);
window.addEventListener('keyup', restart);
return () => {
el.removeEventListener('mousedown', down);
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
window.removeEventListener('keydown', restart);
window.removeEventListener('keyup', restart);
};
}
/** Middle-click recenters the view on the clicked residue, Coot-style: the
* camera target moves, orientation and distance stay. Returns an unsubscribe. */
export function wireMiddleClickRecenter(plugin: PluginContext): () => void {
const sub = plugin.behaviors.interaction.click.subscribe((e) => {
if (!(e.button & B.Flag.Auxilary)) return;
let loci = e.current?.loci;
if (!loci || Loci.isEmpty(loci)) return;
if (StructureElement.Loci.is(loci)) {
loci = StructureElement.Loci.extendToWholeResidues(loci);
}
const sphere = Loci.getBoundingSphere(loci);
if (!sphere) return;
recenterCamera(plugin, sphere.center);
});
return () => sub.unsubscribe();
}
@@ -0,0 +1,55 @@
// Adapted from aaredb (gitea.psi.ch/mx/aaredb, the PSI MX group's own code), frontend/src/components/Structure/molstar/cootTrackball.ts at 8ece2c2.
// Coot 0.9.8's virtual trackball — a port of the SGI trackball.c call Coot made
// on every mouse-motion event (globjects.cc, glarea_motion_notify): spherical
// surface, trackball_size 0.8, incremental from the previous pointer position.
// Coot 1.x kept the very same code but defaults to a "flat" surface
// (trackball_size 12.8, graphics-info-statics.cc), which is near-linear, about
// twice as fast per pixel and never rolls — that is what makes new Coot feel
// different. This module is the old feel.
//
// Coordinates are Coot's: x, y in [-1, 1] with (0, 0) at the window centre and
// +y up; x is normalised by the width and y by the height, so on a wide window a
// vertical drag turns more per pixel than a horizontal one — exactly like Coot.
export const COOT_TRACKBALL_SIZE = 0.8;
export type TrackballRotation = {
/** unit axis in screen space: x right, y up, z toward the viewer */
axis: [number, number, number];
/** radians; the scene turns by this about `axis` (the near face follows the pointer) */
angle: number;
};
/** Coot's window → trackball mapping for a pointer at (x, y) CSS px from the top-left. */
export function toTrackballCoords(x: number, y: number, width: number, height: number): [number, number] {
return [(2 * x - width) / width, (height - 2 * y) / height];
}
/** SGI's tb_project_to_sphere: a sphere of radius r out to r/√2, then a
* hyperbolic sheet, so the surface never ends at the window edge. */
export function projectToSphere(r: number, x: number, y: number): number {
const d = Math.hypot(x, y);
const t = r * Math.SQRT1_2;
return d < t ? Math.sqrt(r * r - d * d) : (t * t) / d;
}
/** The scene rotation for a pointer move p1 → p2. Returns null when the pointer
* did not move. Angle = 2·asin(|p1 − p2|·(size − 0.3)/size), clamped, as in
* Coot's trackball() with its d_mult fudge. */
export function cootTrackball(
p1x: number, p1y: number, p2x: number, p2y: number, size = COOT_TRACKBALL_SIZE,
): TrackballRotation | null {
if (p1x === p2x && p1y === p2y) return null;
const p1z = projectToSphere(size, p1x, p1y);
const p2z = projectToSphere(size, p2x, p2y);
// p1 × p2: for a rightward drag from the centre this is +y, and turning the
// scene about +y by a positive angle moves its near face to the right.
const ax = p1y * p2z - p1z * p2y;
const ay = p1z * p2x - p1x * p2z;
const az = p1x * p2y - p1y * p2x;
const len = Math.hypot(ax, ay, az);
if (!len) return null;
const d = Math.hypot(p1x - p2x, p1y - p2y, p1z - p2z);
const t = Math.max(-1, Math.min(1, (d * (size - 0.3)) / size));
return { axis: [ax / len, ay / len, az / len], angle: 2 * Math.asin(t) };
}
@@ -0,0 +1,37 @@
// Adapted from aaredb (gitea.psi.ch/mx/aaredb, the PSI MX group's own code), frontend/src/components/Structure/molstar/createPlugin.ts at 8ece2c2.
// Headless Mol* plugin (no mol-plugin-ui): the render core + state tree +
// builders, driven entirely by our own charter-styled controls. ALL molstar
// imports in the app live under components/Structure/molstar/ so the lazy
// chunk boundary holds and a version upgrade touches one directory.
// molstar is pinned EXACTLY (5.11.0) — these are lib/ internal paths.
import { PluginContext } from 'molstar/lib/mol-plugin/context';
import { DefaultPluginSpec } from 'molstar/lib/mol-plugin/spec';
import { Color } from 'molstar/lib/mol-util/color';
export type { PluginContext };
export const CANVAS_BG = 0x000000; // black (aaredb: 0x0d1a26, steel/cryo dark stage)
export async function createStructurePlugin(
canvas: HTMLCanvasElement,
container: HTMLDivElement,
): Promise<PluginContext> {
const spec = DefaultPluginSpec();
const plugin = new PluginContext(spec);
await plugin.init();
const ok = await plugin.initViewerAsync(canvas, container);
if (!ok) {
plugin.dispose();
throw new Error('WebGL initialization failed');
}
plugin.canvas3d?.setProps({
renderer: { backgroundColor: Color(CANVAS_BG) },
// No automatic camera resets when the scene's bounds change: the maps
// and the blob marker land seconds after the model (async worker), and
// the default behaviour zoomed the camera back out — losing a blob
// deep-link position and its clip slab. All camera moves are ours
// (loadModel's explicit reset, recenterCamera, flyToBlob).
camera: { manualReset: true },
});
return plugin;
}
@@ -0,0 +1,249 @@
// The density preparation of aaredb's density worker (gitea.psi.ch/mx/aaredb, the PSI MX group's own code,
// frontend/public/structure-worker/density-worker.js at 8ece2c2): crop the periodic map to the model's box plus a
// margin, carve the 2Fo-Fc map around the atoms, encode as CCP4 with the full cell's mean and RMS. aaredb computes
// the full-cell map from an MTZ with gemmi wasm; here the broker serves it (/rugnux/map.ccp4), so it is first read
// back into aaredb's full-cell z-fastest grid (fullCellFromCcp4).
export interface ComputedMap {
name: string;
ccp4: ArrayBuffer;
rmsd: number;
mean: number;
dims: [number, number, number];
}
export interface Cell { a: number; b: number; c: number; alpha: number; beta: number; gamma: number }
export interface CropBox {
min: number[];
max: number[];
marginA: number;
}
export interface FullCellMap {
data: Float32Array; // value(u,v,w) = data[w + nz*(v + ny*u)]
n: [number, number, number];
cell: Cell;
mean: number;
rms: number;
}
// The broker's CCP4 map (any axis order, any start, at least one period along each axis, mean and RMS of the
// whole cell in the header) as the full periodic cell.
export function fullCellFromCcp4(bytes: ArrayBuffer): FullCellMap {
const i32 = new Int32Array(bytes, 0, 256);
const f32 = new Float32Array(bytes, 0, 256);
const count = [i32[0], i32[1], i32[2]]; // NC NR NS
const start = [i32[4], i32[5], i32[6]];
const n: [number, number, number] = [i32[7], i32[8], i32[9]]; // MX MY MZ
const axis = [i32[16] - 1, i32[17] - 1, i32[18] - 1]; // MAPC MAPR MAPS -> x/y/z
const values = new Float32Array(bytes, 1024 + i32[23], count[0] * count[1] * count[2]);
const data = new Float32Array(n[0] * n[1] * n[2]);
const wrap = (i: number, nn: number) => ((i % nn) + nn) % nn;
const g = [0, 0, 0];
let p = 0;
for (let s = 0; s < count[2]; s++)
for (let r = 0; r < count[1]; r++)
for (let c = 0; c < count[0]; c++) {
g[axis[0]] = wrap(start[0] + c, n[axis[0]]);
g[axis[1]] = wrap(start[1] + r, n[axis[1]]);
g[axis[2]] = wrap(start[2] + s, n[axis[2]]);
data[g[2] + n[2] * (g[1] + n[1] * g[0])] = values[p++];
}
return {
data, n,
cell: {a: f32[10], b: f32[11], c: f32[12], alpha: f32[13], beta: f32[14], gamma: f32[15]},
mean: f32[21], rms: f32[54],
};
}
function encodeCcp4(data: Float32Array, dims: number[], start: number[], full: number[], cell: Cell,
mean: number, rms: number): ArrayBuffer {
// dims/start are [x,y,z] grid extents/origins of the (possibly cropped)
// block; full = full-cell sampling counts.
const nx = dims[0], ny = dims[1], nz = dims[2];
const header = new ArrayBuffer(1024);
const i32 = new Int32Array(header);
const f32 = new Float32Array(header);
i32[0] = nz; i32[1] = ny; i32[2] = nx; // NC NR NS (cols=Z fastest)
i32[3] = 2; // MODE 2 = float32
i32[4] = start[2]; i32[5] = start[1]; i32[6] = start[0]; // NCSTART NRSTART NSSTART
i32[7] = full[0]; i32[8] = full[1]; i32[9] = full[2]; // MX MY MZ (full-cell sampling)
f32[10] = cell.a; f32[11] = cell.b; f32[12] = cell.c;
f32[13] = cell.alpha; f32[14] = cell.beta; f32[15] = cell.gamma;
i32[16] = 3; i32[17] = 2; i32[18] = 1; // MAPC MAPR MAPS
let mn = Infinity, mx = -Infinity;
for (let i = 0; i < data.length; i++) {
const v = data[i];
if (v < mn) mn = v;
if (v > mx) mx = v;
}
f32[19] = mn; f32[20] = mx; f32[21] = mean; // DMIN DMAX DMEAN
i32[22] = 1; i32[23] = 0; // ISPG=P1 NSYMBT=0
const u8 = new Uint8Array(header);
u8[208] = 0x4d; u8[209] = 0x41; u8[210] = 0x50; u8[211] = 0x20; // 'MAP '
u8[212] = 0x44; u8[213] = 0x41; u8[214] = 0x00; u8[215] = 0x00; // machst LE
f32[54] = rms; // RMS
i32[55] = 0; // NLABL
const out = new ArrayBuffer(1024 + data.length * 4);
new Uint8Array(out).set(u8, 0);
new Float32Array(out, 1024).set(data);
return out;
}
/* Fractionalization matrix (upper-triangular inverse of the standard
* orthogonalization: a along x, b in the xy-plane). */
function fracMatrix(cell: Cell) {
const d = Math.PI / 180;
const ca = Math.cos(cell.alpha * d), cb = Math.cos(cell.beta * d);
const cg = Math.cos(cell.gamma * d), sg = Math.sin(cell.gamma * d);
const ax = cell.a, bx = cell.b * cg, by = cell.b * sg;
const cx = cell.c * cb, cy = cell.c * (ca - cb * cg) / sg;
const cz = Math.sqrt(Math.max(0, cell.c * cell.c - cx * cx - cy * cy));
return {
m00: 1 / ax, m01: -bx / (ax * by), m02: (bx * cy - by * cx) / (ax * by * cz),
m11: 1 / by, m12: -cy / (by * cz),
m22: 1 / cz,
};
}
function toFrac(M: ReturnType<typeof fracMatrix>, x: number, y: number, z: number) {
return [
M.m00 * x + M.m01 * y + M.m02 * z,
M.m11 * y + M.m12 * z,
M.m22 * z,
];
}
/* Resample the periodic full-cell grid over the model's bounding box (+margin).
* The FFT grid covers exactly one unit cell, but a refined model routinely
* sits partly OUTSIDE the [0,1) box — without this wrap-around crop the mesh
* renders as a displaced cell-shaped block next to the model. Returns
* { data, start: [i0,j0,k0], dims: [ni,nj,nk] } in grid indices (x,y,z axes). */
function cropToBox(data: Float32Array, nx: number, ny: number, nz: number, cell: Cell, box: CropBox) {
const M = fracMatrix(cell);
const m = box.marginA;
const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
for (let corner = 0; corner < 8; corner++) {
const x = (corner & 1 ? box.max[0] + m : box.min[0] - m);
const y = (corner & 2 ? box.max[1] + m : box.min[1] - m);
const z = (corner & 4 ? box.max[2] + m : box.min[2] - m);
const f = toFrac(M, x, y, z);
for (let t = 0; t < 3; t++) {
if (f[t] < lo[t]) lo[t] = f[t];
if (f[t] > hi[t]) hi[t] = f[t];
}
}
const n = [nx, ny, nz];
const start: number[] = [], count: number[] = [];
for (let u = 0; u < 3; u++) {
const i0 = Math.floor(lo[u] * n[u]) - 1;
const i1 = Math.ceil(hi[u] * n[u]) + 1;
// The block is anchored on the MODEL (starts may be negative) and may
// span MORE than one period: refined models routinely extend across
// cell boundaries (a real case spans 1.2 cells along a and b), and the
// map is periodic so repeated samples are correct. Cap at 3 periods as
// a memory sanity bound.
const cnt = Math.min(i1 - i0 + 1, 3 * n[u]);
start.push(i0);
count.push(cnt);
}
const wrap = (i: number, nn: number) => ((i % nn) + nn) % nn;
const out = new Float32Array(count[0] * count[1] * count[2]);
let p = 0;
// output stays z-fastest to match the full-grid layout
for (let iu = 0; iu < count[0]; iu++) {
const gu = wrap(start[0] + iu, nx);
for (let iv = 0; iv < count[1]; iv++) {
const gv = wrap(start[1] + iv, ny);
const rowBase = nz * (gv + ny * gu);
for (let iw = 0; iw < count[2]; iw++) {
out[p++] = data[wrap(start[2] + iw, nz) + rowBase];
}
}
}
return { data: out, start, dims: count };
}
/* Orthogonalization matrix (fractional -> Cartesian), same convention as
* fracMatrix (a along x, b in the xy-plane). */
function orthoMatrix(cell: Cell) {
const d = Math.PI / 180;
const ca = Math.cos(cell.alpha * d), cb = Math.cos(cell.beta * d);
const cg = Math.cos(cell.gamma * d), sg = Math.sin(cell.gamma * d);
const cx = cell.c * cb;
const cy = cell.c * (ca - cb * cg) / sg;
const cz = Math.sqrt(Math.max(0, cell.c * cell.c - cx * cx - cy * cy));
return { ax: cell.a, bx: cell.b * cg, by: cell.b * sg, cx, cy, cz };
}
/* Carve: zero all block values farther than radiusA from every model atom, so
* the mesh hugs the model instead of filling the whole crop box (the classic
* carved map — also a large triangle-count win). Atoms come as a flat xyz
* Float32Array in Cartesian Å. Masked values become exactly 0, which crosses
* neither a positive nor a negative iso level. */
function carveBlock(block: Float32Array, start: number[], dims: number[], n: number[], cell: Cell,
atoms: Float32Array, radiusA: number) {
const M = fracMatrix(cell);
const O = orthoMatrix(cell);
const mask = new Uint8Array(block.length);
const stepR = [
Math.ceil(radiusA / (cell.a / n[0])) + 1,
Math.ceil(radiusA / (cell.b / n[1])) + 1,
Math.ceil(radiusA / (cell.c / n[2])) + 1,
];
const r2 = radiusA * radiusA;
for (let a = 0; a < atoms.length; a += 3) {
const x = atoms[a], y = atoms[a + 1], z = atoms[a + 2];
const f = toFrac(M, x, y, z);
const gi = [f[0] * n[0] - start[0], f[1] * n[1] - start[1], f[2] * n[2] - start[2]];
const lo0 = Math.max(0, Math.floor(gi[0] - stepR[0]));
const hi0 = Math.min(dims[0] - 1, Math.ceil(gi[0] + stepR[0]));
const lo1 = Math.max(0, Math.floor(gi[1] - stepR[1]));
const hi1 = Math.min(dims[1] - 1, Math.ceil(gi[1] + stepR[1]));
const lo2 = Math.max(0, Math.floor(gi[2] - stepR[2]));
const hi2 = Math.min(dims[2] - 1, Math.ceil(gi[2] + stepR[2]));
for (let iu = lo0; iu <= hi0; iu++) {
const fu = (start[0] + iu) / n[0];
for (let iv = lo1; iv <= hi1; iv++) {
const fv = (start[1] + iv) / n[1];
const rowBase = dims[2] * (iv + dims[1] * iu);
for (let iw = lo2; iw <= hi2; iw++) {
const idx = rowBase + iw;
if (mask[idx]) continue;
const fw = (start[2] + iw) / n[2];
const px = O.ax * fu + O.bx * fv + O.cx * fw;
const py = O.by * fv + O.cy * fw;
const pz = O.cz * fw;
const dx = px - x, dy = py - y, dz = pz - z;
if (dx * dx + dy * dy + dz * dz <= r2) mask[idx] = 1;
}
}
}
}
for (let i = 0; i < block.length; i++) {
if (!mask[i]) block[i] = 0;
}
}
/* One map as the worker's onmessage loop makes it: cropped to `crop`, carved around `carveAtoms` (2Fo-Fc only -
* never a difference map: unmodelled blobs sit away from every atom), with the full cell's mean and RMS. */
export function prepareMap(name: string, full: FullCellMap, crop: CropBox | null, carveAtoms: Float32Array | null,
carveRadiusA = 4.5): ComputedMap {
const [nx, ny, nz] = full.n;
let data = full.data;
let start = [0, 0, 0];
let dims = [nx, ny, nz];
if (crop) {
const cropped = cropToBox(data, nx, ny, nz, full.cell, crop);
data = cropped.data;
start = cropped.start;
dims = cropped.dims;
} else {
data = data.slice();
}
if (carveAtoms)
carveBlock(data, start, dims, [nx, ny, nz], full.cell, carveAtoms, carveRadiusA);
const ccp4 = encodeCcp4(data, dims, start, [nx, ny, nz], full.cell, full.mean, full.rms);
return { name, ccp4, rmsd: full.rms, mean: full.mean, dims: [dims[0], dims[1], dims[2]] };
}
@@ -0,0 +1,109 @@
// Adapted from aaredb (gitea.psi.ch/mx/aaredb, the PSI MX group's own code), frontend/src/components/Structure/molstar/loadMaps.ts (+ the anomalous layer) at 8ece2c2.
import { PluginContext } from 'molstar/lib/mol-plugin/context';
import { StateTransforms } from 'molstar/lib/mol-plugin-state/transforms';
import { createVolumeRepresentationParams } from 'molstar/lib/mol-plugin-state/helpers/volume-representation-params';
import { Volume } from 'molstar/lib/mol-model/volume';
import { Color } from 'molstar/lib/mol-util/color';
import { setSubtreeVisibility } from 'molstar/lib/mol-plugin/behavior/static/state';
import type { ComputedMap } from './densityCrop';
// Density volumes in the Mol* state tree. σ sliders map user-σ to ABSOLUTE iso
// values via the map's full-grid mean/rms (reported by the worker) — Mol*'s own
// relative sigma would re-derive stats from whatever grid it holds, which stops
// being crystallographically meaningful the moment we crop. A σ change is a
// representation param update (re-mesh only — no FFT, no re-upload).
export const MAP_COLORS = {
twofofc: 0x4d94d6, // steel-blue
fofcPos: 0x35b96a, // green
fofcNeg: 0xd6564d, // red
anom: 0xe040e0, // magenta (anomalous difference map, +sigma only)
} as const;
export interface MapLayer {
volumeRef: string;
reprRefs: string[]; // one for 2fofc and anom, two (+/−) for fofc
rmsd: number;
mean: number;
}
const abs = (map: { mean: number; rmsd: number }, sigma: number) =>
Volume.IsoValue.absolute(map.mean + sigma * map.rmsd);
async function addIso(
plugin: PluginContext,
volumeRef: string,
map: ComputedMap,
sigma: number,
color: number,
): Promise<string> {
const volumeCell = plugin.state.data.select(volumeRef)[0];
const repr = plugin
.build()
.to(volumeRef)
.apply(
StateTransforms.Representation.VolumeRepresentation3D,
createVolumeRepresentationParams(plugin, volumeCell?.obj?.data, {
type: 'isosurface',
typeParams: { isoValue: abs(map, sigma), visuals: ['wireframe'], sizeFactor: 1 },
color: 'uniform',
colorParams: { value: Color(color) },
}),
);
await repr.commit();
// The mesh must NOT be pickable: a pickable isosurface shields the model,
// so residue clicks and middle-click recenter would hit map cells instead
// of atoms. Pickability is runtime state on the INNER representation
// (cell.obj.data.repr — the cell data itself has no setState), and the
// pick buffer needs a redraw to drop the mesh.
const reprCell = plugin.state.data.select(repr.ref)[0];
const reprObj = (reprCell?.obj?.data as { repr?: { setState?: (s: { pickable: boolean }) => void } })?.repr;
reprObj?.setState?.({ pickable: false });
plugin.canvas3d?.requestDraw?.();
return repr.ref;
}
export async function loadMapLayer(
plugin: PluginContext,
map: ComputedMap,
kind: '2fofc' | 'fofc' | 'anom',
sigma: number,
): Promise<MapLayer> {
const data = await plugin.builders.data.rawData({
data: new Uint8Array(map.ccp4),
label: kind,
});
const parsed = await plugin.dataFormats.get('ccp4')!.parse(plugin, data);
const volumeRef: string = parsed.volume.ref;
const reprRefs: string[] = [];
if (kind === '2fofc') {
reprRefs.push(await addIso(plugin, volumeRef, map, sigma, MAP_COLORS.twofofc));
} else if (kind === 'anom') {
reprRefs.push(await addIso(plugin, volumeRef, map, sigma, MAP_COLORS.anom));
} else {
reprRefs.push(await addIso(plugin, volumeRef, map, +sigma, MAP_COLORS.fofcPos));
reprRefs.push(await addIso(plugin, volumeRef, map, -sigma, MAP_COLORS.fofcNeg));
}
return { volumeRef, reprRefs, rmsd: map.rmsd, mean: map.mean };
}
/** σ slider update: param-only change, re-mesh without recompute. */
export async function updateLayerSigma(
plugin: PluginContext,
layer: MapLayer,
sigma: number,
): Promise<void> {
const sigmas = layer.reprRefs.length === 2 ? [+sigma, -sigma] : [sigma];
const b = plugin.build();
layer.reprRefs.forEach((ref, i) => {
b.to(ref).update((old: { type: { params: { isoValue: unknown } } }) => {
old.type.params.isoValue = abs(layer, sigmas[i]);
});
});
await b.commit();
}
export function setLayerVisible(plugin: PluginContext, layer: MapLayer, visible: boolean): void {
setSubtreeVisibility(plugin.state.data, layer.volumeRef, !visible); // arg = isHidden
}
@@ -0,0 +1,129 @@
// Adapted from aaredb (gitea.psi.ch/mx/aaredb, the PSI MX group's own code), frontend/src/components/Structure/molstar/loadModel.ts (format option, atom coordinates) at 8ece2c2.
import { PluginContext } from 'molstar/lib/mol-plugin/context';
import { StateObjectSelector } from 'molstar/lib/mol-state';
import { setSubtreeVisibility } from 'molstar/lib/mol-plugin/behavior/static/state';
import { Color } from 'molstar/lib/mol-util/color';
// Chain palette chosen to stay clear of the density colors: no greens (Fo-Fc+),
// no reds/oranges (Fo-Fc− reads orange on some screens) and no strong blues
// (2Fo-Fc). Silver → violet → pink → sand → slate, cycled per chain.
const CHAIN_COLORS = [0xc2cbd6, 0xb18ae0, 0xe08ac2, 0xd8c9a3, 0x8291a5].map(Color);
const CHAIN_PALETTE = {
palette: {
name: 'colors' as const,
params: { list: { kind: 'set' as const, colors: CHAIN_COLORS } },
},
};
// Load a PDB model from bytes into the plugin's state tree. All representations
// are built up front with stable refs; "representation mode" and waters are
// pure visibility flips (setSubtreeVisibility), so switching is instant.
export type ReprMode = 'cartoon' | 'sticks' | 'both';
export interface LoadedModel {
structure: StateObjectSelector;
refs: {
polymerCartoon?: string;
polymerSticks?: string;
ligand?: string;
water?: string;
};
}
export async function loadModel(
plugin: PluginContext,
bytes: ArrayBuffer,
label: string,
opts?: { resetCamera?: boolean, format?: 'pdb' | 'mmcif' },
): Promise<LoadedModel> {
const text = new TextDecoder().decode(bytes);
const data = await plugin.builders.data.rawData({ data: text, label });
const trajectory = await plugin.builders.structure.parseTrajectory(data, opts?.format ?? 'pdb');
const model = await plugin.builders.structure.createModel(trajectory);
const structure = await plugin.builders.structure.createStructure(model);
const refs: LoadedModel['refs'] = {};
const polymer = await plugin.builders.structure.tryCreateComponentStatic(structure, 'polymer');
if (polymer) {
const cartoon = await plugin.builders.structure.representation.addRepresentation(polymer, {
type: 'cartoon',
color: 'chain-id',
colorParams: CHAIN_PALETTE,
});
refs.polymerCartoon = cartoon.ref;
const sticks = await plugin.builders.structure.representation.addRepresentation(polymer, {
type: 'ball-and-stick',
color: 'element-symbol',
});
refs.polymerSticks = sticks.ref;
}
const ligand = await plugin.builders.structure.tryCreateComponentStatic(structure, 'ligand');
if (ligand) {
const r = await plugin.builders.structure.representation.addRepresentation(ligand, {
type: 'ball-and-stick',
});
refs.ligand = r.ref;
}
const water = await plugin.builders.structure.tryCreateComponentStatic(structure, 'water');
if (water) {
const r = await plugin.builders.structure.representation.addRepresentation(water, {
type: 'ball-and-stick',
});
refs.water = r.ref;
}
// defaults: cartoon + ligand sticks; polymer sticks and waters hidden
if (refs.polymerSticks) setSubtreeVisibility(plugin.state.data, refs.polymerSticks, true);
if (refs.water) setSubtreeVisibility(plugin.state.data, refs.water, true);
// Skippable so a pipeline/pair switch keeps the user's view and clip —
// the models of one run share the same crystal frame (assuming they align).
if (opts?.resetCamera !== false) plugin.managers.camera.reset();
return { structure, refs };
}
/** Apply a representation mode + waters flag as visibility flips. */
export function applyModelDisplay(
plugin: PluginContext,
refs: LoadedModel['refs'],
mode: ReprMode,
showWaters: boolean,
): void {
const s = plugin.state.data;
// setSubtreeVisibility's boolean is "isHidden"
if (refs.polymerCartoon) setSubtreeVisibility(s, refs.polymerCartoon, mode === 'sticks');
if (refs.polymerSticks) setSubtreeVisibility(s, refs.polymerSticks, mode === 'cartoon');
if (refs.water) setSubtreeVisibility(s, refs.water, !showWaters);
}
export async function clearScene(plugin: PluginContext): Promise<void> {
await plugin.clear();
}
/** Cartesian bounding box (Å) of a loaded structure — the density worker crops
* the periodic map to this region (+margin) so the mesh hugs the model instead
* of drawing one displaced unit-cell block. */
export function getStructureBox(
structure: StateObjectSelector,
): { min: number[]; max: number[] } | null {
const data = (structure as { data?: { boundary?: { box?: { min: ArrayLike<number>; max: ArrayLike<number> } } } }).data;
const box = data?.boundary?.box;
if (!box) return null;
return { min: Array.from(box.min).slice(0, 3), max: Array.from(box.max).slice(0, 3) };
}
/** Cartesian coordinates (A) of every atom of a loaded structure, xyz flat - what the 2Fo-Fc map is carved
* around (aaredb reads them from the PDB text, parsePdbAtoms; here the model comes as mmCIF). */
export function getStructureAtoms(structure: StateObjectSelector): Float32Array {
const s = (structure as { data?: { units: ReadonlyArray<{ elements: ArrayLike<number>, conformation: {
x: (e: number) => number, y: (e: number) => number, z: (e: number) => number } }> } }).data;
if (!s) return new Float32Array(0);
const xyz: number[] = [];
for (const unit of s.units)
for (let i = 0; i < unit.elements.length; i++) {
const e = unit.elements[i];
xyz.push(unit.conformation.x(e), unit.conformation.y(e), unit.conformation.z(e));
}
return new Float32Array(xyz);
}