diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d24cf6086..0ab6a4cdc 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -105,8 +105,9 @@ function PanelColumns({ columns = 2, children }: { columns?: number; children: R
// An item either renders a panel inside the app (render) or links out to an
// external page in a new tab (href), e.g. the source repository.
+// wide: the page uses the screen's width instead of the 1300 px reading column
type NavItem = { id: string; label: string; icon: ReactNode; disabled?: boolean;
- href?: string; render?: () => ReactNode };
+ href?: string; render?: () => ReactNode; wide?: boolean };
type NavSection = { subheader?: string; items: NavItem[] };
/** Documentation / API reference shown inside the app area, with a new-tab escape hatch. */
@@ -192,7 +193,7 @@ function App() {
{ id: 'data-collection', label: 'Data collection', icon: , render: () => (
)},
- { id: 'rugnux', label: 'Rugnux live', icon: , render: () => (
+ { id: 'rugnux', label: 'Rugnux live', icon: , wide: true, render: () => (
)},
]},
@@ -308,7 +309,7 @@ function App() {
-
+
{activeItem.render?.()}
diff --git a/frontend/src/components/RugnuxPage.tsx b/frontend/src/components/RugnuxPage.tsx
index 9e062c3d8..90a951e34 100644
--- a/frontend/src/components/RugnuxPage.tsx
+++ b/frontend/src/components/RugnuxPage.tsx
@@ -7,21 +7,26 @@ import {useTokenRequired} from "../auth";
import EmptyState from "./EmptyState";
import RugnuxTimeline from "./RugnuxTimeline";
import RugnuxSettings from "./RugnuxSettings";
-import {CutAlert, lastImageTime, ProbeAlert, RunMessage, StateChip, StepTable, VerdictAlert} from "./RugnuxParts";
+import {CutAlert, lastImageTime, ModelScores, ProbeAlert, RunMessage, StateChip, StepTable, VerdictAlert} from "./RugnuxParts";
import BoltIcon from "@mui/icons-material/Bolt";
import {lazy, Suspense} from "react";
+import {keyframes} from "@mui/system";
// Mol* is large: loaded only when there is a model to show.
const RugnuxStructureViewer = lazy(() => import("./RugnuxStructureViewer"));
-// The summary rows rugnux writes; the ones a crystallographer looks at first get a stronger tile, and
-// space group and cell share a row.
+// The summary rows rugnux writes; the ones a crystallographer looks at first get a stronger tile.
const KEY_ROW = /unit cell|space group|resolution|signal|r-?free|model/i;
-const PAIRED_ROW = /space group|unit cell/i;
-function Section({title, children}: { title: string, children: React.ReactNode }) {
+// The model section's entrance under the timeline, once the run has placed a model
+const slideIn = keyframes`
+ from { opacity: 0; transform: translateY(24px); }
+ to { opacity: 1; transform: none; }
+`;
+
+function Section({title, children, sx}: { title: string, children: React.ReactNode, sx?: object }) {
return (
-
+
{title}
@@ -86,12 +91,12 @@ function SummaryTiles({s}: { s: rugnux_live_status }) {
if (s.summary.length === 0)
return null;
return (
-
+
{s.summary.map((row, i) => {
const key = KEY_ROW.test(row.label);
const short = row.value.length <= 24;
return (
-
step.title === "Molecular replacement")?.duration_s;
+ return (
+
+
+ {s.placed_model &&
+ Loading viewer...}>
+
+
+ }
+
+ );
+}
+
function RugnuxPage({measuring}: { measuring: boolean }) {
const {data: settings} = useQuery({...getConfigRugnuxOptions(), refetchInterval: 2000});
const enabled = settings?.enable === true;
@@ -138,7 +160,9 @@ function RugnuxPage({measuring}: { measuring: boolean }) {
-
+ {s.model && s.state !== 'Running' && }
+
@@ -155,12 +179,6 @@ function RugnuxPage({measuring}: { measuring: boolean }) {
- {s.placed_model && s.state !== 'Running' &&
- }>
-
-
- }
);
diff --git a/frontend/src/components/RugnuxParts.tsx b/frontend/src/components/RugnuxParts.tsx
index cbd6502a0..dbb4209d4 100644
--- a/frontend/src/components/RugnuxParts.tsx
+++ b/frontend/src/components/RugnuxParts.tsx
@@ -1,7 +1,7 @@
import {Alert, AlertTitle, Box, Chip, Paper, Table, TableBody, TableCell, TableContainer, TableRow, Typography} from "@mui/material";
import {indigo, lime, deepOrange, grey} from "@mui/material/colors";
import {SxProps, Theme} from "@mui/material/styles";
-import {rugnux_live_status, rugnux_live_step} from "../client";
+import {rugnux_live_model, rugnux_live_status, rugnux_live_step} from "../client";
// Pieces of the rugnux live status shared by the dashboard card and the Rugnux page.
@@ -113,3 +113,58 @@ export function StepTable({steps}: { steps: rugnux_live_step[] }) {
);
}
+
+function ScoreTile({label, value, sub, accent}: { label: string, value: string, sub?: string, accent?: boolean }) {
+ return (
+
+ {label}
+
+ {value}
+ {sub && {sub}}
+
+ );
+}
+
+const PLACEMENT: Record = {MR: "MR", RIGID_BODY: "Rigid body", NOT_PLACED: "Not placed"};
+
+// What the run made of the model, in the numbers of the report's MODEL VALIDATION section; mrSeconds is how long
+// the molecular-replacement step took. Nothing is refined, so the R-factors are those of the model as placed.
+// The strongest anomalous peak is shown only where the data carry anomalous signal: without it, it is noise.
+export function ModelScores({m, mrSeconds, anomalousSignal}:
+ { m: rugnux_live_model, mrSeconds?: number, anomalousSignal: boolean }) {
+ const tiles: { label: string, value: string, sub?: string, accent?: boolean }[] = [];
+ let placedSub = m.reason || m.mr_decision;
+ if (m.placement === "MR" && m.copies !== undefined)
+ placedSub = `${m.copies} ${m.copies === 1 ? "copy" : "copies"} in ${m.mr_space_group}`
+ + (mrSeconds !== undefined ? `, found in ${mrSeconds.toFixed(1)} s` : "");
+ else if (m.placement === "RIGID_BODY")
+ placedSub = m.rigid_body_shift_A !== undefined
+ ? `moved ${m.rigid_body_angle_deg?.toFixed(1)}° and ${m.rigid_body_shift_A.toFixed(2)} Å`
+ : "fits where it is";
+ tiles.push({label: "Placement", value: PLACEMENT[m.placement] ?? m.placement, sub: placedSub,
+ accent: m.placement !== "NOT_PLACED"});
+ if (m.tf_z !== undefined)
+ tiles.push({label: "TFZ", value: m.tf_z.toFixed(1),
+ sub: [m.rf_z !== undefined && `RFZ ${m.rf_z.toFixed(1)}`, m.llg_z !== undefined && `LLG-Z ${m.llg_z.toFixed(1)}`,
+ m.mr_d_min !== undefined && `search to ${m.mr_d_min.toFixed(1)} Å`].filter(Boolean).join(" · ")});
+ if (m.r_free !== undefined)
+ tiles.push({label: "R-free", value: m.r_free.toFixed(3), accent: true,
+ sub: `R-work ${m.r_work?.toFixed(3)} · model as placed, unrefined`});
+ if (m.cc_model !== undefined)
+ tiles.push({label: "CC (model, data)", value: m.cc_model.toFixed(2), sub: "intensities, all reflections"});
+ if (m.fit_sigma !== undefined)
+ tiles.push({label: "Fit over random", value: `${m.fit_sigma >= 0 ? "+" : ""}${m.fit_sigma.toFixed(1)} σ`,
+ sub: "R-work against the model in random orientations"});
+ if (m.map_atom_sigma !== undefined)
+ tiles.push({label: "2Fo−Fc at atoms", value: `${m.map_atom_sigma.toFixed(1)} σ`, sub: "mean at the atom centres"});
+ if (anomalousSignal && m.anomalous_site && m.anomalous_site_sigma !== undefined)
+ tiles.push({label: "Anomalous peak", value: `${m.anomalous_site_sigma.toFixed(1)} σ`, sub: m.anomalous_site});
+ return (
+
+ {tiles.map(t => )}
+
+ );
+}
diff --git a/frontend/src/components/RugnuxStructureViewer.tsx b/frontend/src/components/RugnuxStructureViewer.tsx
index cd92d0108..180bc7a58 100644
--- a/frontend/src/components/RugnuxStructureViewer.tsx
+++ b/frontend/src/components/RugnuxStructureViewer.tsx
@@ -7,11 +7,14 @@ import {applyModelDisplay, clearScene, getStructureAtoms, getStructureBox, Loade
from "./structure/loadModel";
import {loadMapLayer, MAP_COLORS, MapLayer, setLayerVisible, updateLayerSigma} from "./structure/loadMaps";
import {fullCellFromCcp4, prepareMap} from "./structure/densityCrop";
+import {revealCamera} from "./structure/reveal";
// 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
+// meshes, crop and carve, panel and defaults - on a black stage. Departures for the live page: every map is carved
+// within the same radius of the atoms (aaredb carves 2Fo-Fc only), Fo-Fc starts at +-3.5 sigma, the anomalous map
+// is shown at first only where the data carry anomalous signal, and the first model makes an entrance (reveal.ts). Mol* is only loaded with this component
// (React.lazy in RugnuxPage), and every Mol* import lives in ./structure.
type Kind = 'twofofc' | 'fofc' | 'anom';
@@ -60,8 +63,14 @@ async function fetchBytes(path: string, signal: AbortSignal): Promise window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
+const nextFrames = () => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
+
+// resultKey: changes when a new result is there to load; anomalous: it has an anomalous difference map;
+// anomalousSignal: the data carry usable anomalous signal, so that map is worth showing from the start.
+function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal}:
+ { resultKey: string, anomalous: boolean, anomalousSignal: boolean }) {
const pluginRef = useRef(null);
const [pluginReady, setPluginReady] = useState(0);
const [error, setError] = useState();
@@ -73,10 +82,14 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom
// First load frames the molecule; later results keep the user's camera and clip (aaredb StructureView.tsx:117-121)
const sceneLoadedRef = useRef(false);
const [hasMap, setHasMap] = useState>({twofofc: false, fofc: false, anom: false});
+ // The stage stays dark until the first model and its maps are in, then fades up as the camera eases in.
+ const [revealed, setRevealed] = useState(false);
- // 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});
+ // aaredb factory defaults (state/viewerDefaults.ts:16-23), but Fo-Fc at +-3.5 sigma rather than 3: carved or not,
+ // a difference map at 3 sigma over a whole molecule shows dozens of noise peaks; anomalous as a difference map,
+ // +4 sigma
+ const [show, setShow] = useState>({twofofc: true, fofc: true, anom: anomalousSignal});
+ const [sigma, setSigma] = useState>({twofofc: 1.0, fofc: 3.5, anom: 4.0});
const [reprMode, setReprMode] = useState('cartoon');
const [showWaters, setShowWaters] = useState(false);
const displayRef = useRef({show, sigma, reprMode, showWaters});
@@ -87,8 +100,8 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom
setPluginReady(n => n + 1);
}, []);
- // 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.
+ // The model, then its maps (aaredb StructureView.tsx maybeLoad / computeAndAttachMaps, 212-295): each map cropped
+ // to the model's box + 6 A and carved 4.5 A around the atoms.
useEffect(() => {
const plugin = pluginRef.current;
if (!plugin)
@@ -113,7 +126,8 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom
setError("No placed model");
return;
}
- const loaded = await loadModel(plugin, cif, 'model', {resetCamera: !sceneLoadedRef.current, format: 'mmcif'});
+ const firstScene = !sceneLoadedRef.current;
+ const loaded = await loadModel(plugin, cif, 'model', {resetCamera: firstScene, format: 'mmcif'});
sceneLoadedRef.current = true;
modelRefsRef.current = loaded.refs;
const d = displayRef.current;
@@ -128,14 +142,18 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom
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 computed = prepareMap(name, fullCellFromCcp4(bytes), crop, 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}));
}
+ if (firstScene && !abort.signal.aborted) {
+ await nextFrames(); // the camera reset loadModel asked for is applied on a draw
+ if (!reducedMotion())
+ revealCamera(plugin, REVEAL_MS);
+ }
} catch (e) {
if (!abort.signal.aborted)
setError(e instanceof Error ? e.message : String(e));
@@ -143,6 +161,7 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom
if (!abort.signal.aborted) {
setModelLoading(false);
setMapsLoading(false);
+ setRevealed(true);
}
}
})();
@@ -182,11 +201,15 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom
return (
-
-
- {modelLoading && !error &&
+
+
+
+ {(modelLoading || (mapsLoading && !revealed)) && !error &&
- loading model…
+
+ {modelLoading ? 'loading model…' : 'computing density maps…'}
}
diff --git a/frontend/src/components/structure/densityCrop.ts b/frontend/src/components/structure/densityCrop.ts
index 36fb3bbf9..3cf1597fe 100644
--- a/frontend/src/components/structure/densityCrop.ts
+++ b/frontend/src/components/structure/densityCrop.ts
@@ -1,6 +1,6 @@
// 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
+// margin, carve it 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).
@@ -226,8 +226,9 @@ function carveBlock(block: Float32Array, start: number[], dims: number[], n: num
}
}
-/* 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. */
+/* One map as the worker's onmessage loop makes it: cropped to `crop`, carved around `carveAtoms`, with the full
+ * cell's mean and RMS. aaredb carves the 2Fo-Fc map only; the live page carves the difference maps as well, within
+ * the same radius, so what it shows is the density about the model - unmodelled blobs further away are left out. */
export function prepareMap(name: string, full: FullCellMap, crop: CropBox | null, carveAtoms: Float32Array | null,
carveRadiusA = 4.5): ComputedMap {
const [nx, ny, nz] = full.n;
diff --git a/frontend/src/components/structure/reveal.ts b/frontend/src/components/structure/reveal.ts
new file mode 100644
index 000000000..14a9e8103
--- /dev/null
+++ b/frontend/src/components/structure/reveal.ts
@@ -0,0 +1,16 @@
+import { PluginContext } from 'molstar/lib/mol-plugin/context';
+import { Quat, Vec3 } from 'molstar/lib/mol-math/linear-algebra';
+
+// The entrance of a freshly placed model: the camera starts further out and turned a little about the vertical,
+// then eases in to the view a plain reset frames. Only the camera moves, and it ends exactly where the reset put
+// it, so nothing after the reveal differs from a viewer without one.
+export function revealCamera(plugin: PluginContext, durationMs: number): void {
+ const camera = plugin.canvas3d?.camera;
+ if (!camera) return;
+ const end = camera.getSnapshot();
+ const dir = Vec3.sub(Vec3(), end.position, end.target);
+ Vec3.transformQuat(dir, dir, Quat.setAxisAngle(Quat(), end.up, -0.6)); // about 35 degrees
+ Vec3.scale(dir, dir, 2.2);
+ camera.setState({ ...end, position: Vec3.add(Vec3(), end.target, dir) }, 0);
+ camera.setState(end, durationMs, { easing: 'cubic-out' });
+}