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:
1 parent
543d7548c7
commit
2df1d8a2ad
8 files changed
+1059
-294
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user