diff --git a/frontend/src/components/RugnuxStructureViewer.tsx b/frontend/src/components/RugnuxStructureViewer.tsx
index 10f1eca74..dc538da6a 100644
--- a/frontend/src/components/RugnuxStructureViewer.tsx
+++ b/frontend/src/components/RugnuxStructureViewer.tsx
@@ -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}) => (
+ {children}
+);
+
+// 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}) => (
+
+
+
+ {label}
+
+ {sigma.toFixed(1)} σ
+ onToggle(e.target.checked)}/>
+
+ 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}}}/>
+
+);
async function fetchBytes(path: string, signal: AbortSignal): Promise {
const res = await fetch(path, {headers: authHeaders(), signal});
@@ -41,142 +60,34 @@ async function fetchBytes(path: string, signal: AbortSignal): Promise 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) {
- 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): Promise {
- 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) {
- 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(null);
- const [plugin, setPlugin] = useState();
+ const pluginRef = useRef(null);
+ const [pluginReady, setPluginReady] = useState(0);
const [error, setError] = useState();
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>>({});
+ const modelRefsRef = useRef({});
+ const sigmaTimer = useRef | null>(null);
+ const [hasMap, setHasMap] = useState>({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>({twofofc: true, fofc: true, anom: true});
+ const [sigma, setSigma] = useState>({twofofc: 1.0, fofc: 3.0, anom: 4.0});
+ const [reprMode, setReprMode] = useState('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 | 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 (
-
-
-
- {loading &&
+
+
+
+ {loading &&
Loading model and maps...}
- {error && {error}}
-
-
- setShow2(e.target.checked)}/>}
- label={
- 2mFo-DFc {sigma2.toFixed(1)} σ}/>
- setSigma2(v as number)} aria-label="2mFo-DFc contour level"/>
-
- {hasFofc &&
- setShow1(e.target.checked)}/>}
- label={
- mFo-DFc ±{sigma1.toFixed(1)} σ}/>
- setSigma1(v as number)} aria-label="mFo-DFc contour level"/>
- }
- {hasAnom &&
- setShowA(e.target.checked)}/>}
- label={
- anomalous {sigmaA.toFixed(1)} σ}/>
- setSigmaA(v as number)} aria-label="Anomalous contour level"/>
- }
-
-
-
- Maps within {radius > 0 ? `${radius} Å of the centre` : "the whole cell"}
- setRadius(v as number)} aria-label="Map radius"/>
-
-
- 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.
-
-
+
+ {error && {error}}
+ Model
+
+ {(['cartoon', 'sticks', 'both'] as const).map((m) => (
+ 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}
+ ))}
+
+
+ Waters
+ setDisplay(reprMode, e.target.checked)}/>
+
+
+ Maps
+ {hasMap.twofofc && toggleLayer('twofofc', v)} sigma={sigma.twofofc}
+ min={0.5} max={4} step={0.1} onSigma={(v) => applySigma('twofofc', v)}
+ disabled={mapsDisabled}/>}
+ {hasMap.fofc && toggleLayer('fofc', v)} sigma={sigma.fofc}
+ min={1} max={8} step={0.5} onSigma={(v) => applySigma('fofc', v)}
+ disabled={mapsDisabled}/>}
+ {hasMap.anom && toggleLayer('anom', v)} sigma={sigma.anom}
+ min={1} max={8} step={0.5} onSigma={(v) => applySigma('anom', v)}
+ disabled={mapsDisabled}/>}
+ {!loading && !hasMap.twofofc &&
+ No maps}
+
+ Controls
+ {([
+ ['left drag', 'rotate'],
+ ['right drag', 'zoom'],
+ ['middle drag', 'pan'],
+ ['middle click', 'center on residue'],
+ ['scroll', 'clip'],
+ ] as const).map(([k, v]) => (
+
+ {k}
+ {v}
+
+ ))}
+
+
);
}
diff --git a/frontend/src/components/structure/MolstarCanvas.tsx b/frontend/src/components/structure/MolstarCanvas.tsx
new file mode 100644
index 000000000..7ee9c5459
--- /dev/null
+++ b/frontend/src/components/structure/MolstarCanvas.tsx
@@ -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
snapshots only.
+
+const MolstarCanvas: React.FC<{
+ onReady: (plugin: PluginContext) => void;
+}> = ({ onReady }) => {
+ const containerRef = useRef(null);
+ const pluginRef = useRef(null);
+ const [contextLost, setContextLost] = useState(false);
+ const [initError, setInitError] = useState(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